From 7670c488a750e55db057fd5078084a7fc81c9c98 Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Thu, 16 Apr 2026 01:18:12 +0900 Subject: [PATCH 01/75] feat: add process and task APIs, enhance AgentsApi with process support --- packages/client/src/client.ts | 8 ++ packages/client/src/store/AgentsApi.ts | 49 ++++++- packages/client/src/store/ProcessApi.ts | 37 +++++ packages/client/src/store/TaskApi.ts | 51 +++++++ packages/client/src/store/client.ts | 4 + packages/client/src/store/index.ts | 3 +- packages/common/src/access-control.ts | 3 + packages/common/src/store/agent-run.ts | 174 +++++++++++++++++++++--- packages/common/src/store/index.ts | 3 +- packages/common/src/store/process.ts | 127 +++++++++++++++++ packages/common/src/store/task.ts | 70 ++++++++++ 11 files changed, 507 insertions(+), 22 deletions(-) create mode 100644 packages/client/src/store/ProcessApi.ts create mode 100644 packages/client/src/store/TaskApi.ts create mode 100644 packages/common/src/store/process.ts create mode 100644 packages/common/src/store/task.ts diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 90589997d..80f4cd92e 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -278,6 +278,14 @@ export class VertesiaClient extends AbstractFetchClient { return this.store.files; } + get processes() { + return this.store.processes; + } + + get tasks() { + return this.store.tasks; + } + /** * Alias for store.types */ diff --git a/packages/client/src/store/AgentsApi.ts b/packages/client/src/store/AgentsApi.ts index e6697f5d6..b12751ca5 100644 --- a/packages/client/src/store/AgentsApi.ts +++ b/packages/client/src/store/AgentsApi.ts @@ -11,6 +11,7 @@ import { CompactMessage, ConversationActivityState, CreateAgentRunPayload, + CreateProcessRunPayload, ErrorAnalyticsResponse, FirstResponseBehaviorAnalyticsResponse, LatencyAnalyticsResponse, @@ -18,6 +19,8 @@ import { ListWorkflowRunsResponse, parseMessage, PromptSizeAnalyticsResponse, + ProcessRun, + ProcessState, RunsByAgentAnalyticsResponse, SearchAgentRunsQuery, SearchAgentRunsResponse, @@ -52,7 +55,13 @@ export class AgentsApi extends ApiTopic { */ start>( payload: CreateAgentRunPayload, - ): Promise> { + ): Promise>; + start>( + payload: CreateProcessRunPayload, + ): Promise; + start>( + payload: CreateAgentRunPayload | CreateProcessRunPayload, + ): Promise | ProcessRun> { return this.post('/', { payload }); } @@ -79,10 +88,22 @@ export class AgentsApi extends ApiTopic { return this.get(`/${id}`); } + retrieveProcess(id: string): Promise { + return this.get(`/${id}`); + } + /** * List agent runs with optional filters. */ list(query?: ListAgentRunsQuery): Promise { + return this.get('/', { query: this.buildListQueryParams(query) }); + } + + listProcessRuns(query?: Omit): Promise { + return this.get('/', { query: this.buildListQueryParams({ ...query, run_kind: 'process' }) }); + } + + private buildListQueryParams(query?: ListAgentRunsQuery): Record { const params: Record = {}; if (query?.id) params.id = query.id; if (query?.status) { @@ -93,11 +114,13 @@ export class AgentsApi extends ApiTopic { if (query?.since) params.since = query.since.toISOString(); if (query?.schedule_id) params.schedule_id = query.schedule_id; if (query?.type) params.type = query.type; + if (query?.run_type) params.run_type = Array.isArray(query.run_type) ? query.run_type.join(',') : query.run_type; + if (query?.run_kind) params.run_kind = query.run_kind; if (query?.limit) params.limit = String(query.limit); if (query?.offset) params.offset = String(query.offset); if (query?.sort) params.sort = query.sort; if (query?.order) params.order = query.order; - return this.get('/', { query: params }); + return params; } /** @@ -145,6 +168,26 @@ export class AgentsApi extends ApiTopic { return this.post(`/${id}/fork`, {}); } + getContext(id: string): Promise<{ run_id: string; current_node: string; context: Record }> { + return this.get(`/${id}/context`); + } + + getHistory(id: string): Promise<{ run_id: string; current_node: string; node_history: ProcessState['node_history'] }> { + return this.get(`/${id}/history`); + } + + advance(id: string, payload?: { target?: string; reason?: string }): Promise<{ message: string }> { + return this.post(`/${id}/advance`, { payload: payload ?? {} }); + } + + retryNode(id: string, payload?: { node?: string; reason?: string }): Promise<{ message: string }> { + return this.post(`/${id}/retry-node`, { payload: payload ?? {} }); + } + + answerTask(id: string, taskId: string, result: Record): Promise<{ message: string }> { + return this.post(`/${id}/answer-task`, { payload: { task_id: taskId, result } }); + } + /** * Update agent run status/metadata. * Called by workflow activities to sync lifecycle state. @@ -164,6 +207,8 @@ export class AgentsApi extends ApiTopic { archived_at?: string; archive_version?: number; last_archive_error?: string; + sequence?: number; + process_state?: ProcessState; }, ): Promise { return this.post(`/${id}/status`, { payload: update }); diff --git a/packages/client/src/store/ProcessApi.ts b/packages/client/src/store/ProcessApi.ts new file mode 100644 index 000000000..59e1321bf --- /dev/null +++ b/packages/client/src/store/ProcessApi.ts @@ -0,0 +1,37 @@ +import { ApiTopic, ClientBase } from '@vertesia/api-fetch-client'; +import { + CreateProcessDefinitionPayload, + ProcessDefinition, + UpdateProcessDefinitionPayload, +} from '@vertesia/common'; + +export class ProcessApi extends ApiTopic { + constructor(parent: ClientBase) { + super(parent, '/api/v1/processes'); + } + + list(query?: { status?: string; process?: string; limit?: number; offset?: number }): Promise { + const params: Record = {}; + if (query?.status) params.status = query.status; + if (query?.process) params.process = query.process; + if (query?.limit != null) params.limit = String(query.limit); + if (query?.offset != null) params.offset = String(query.offset); + return this.get('/', { query: params }); + } + + retrieve(id: string): Promise { + return this.get(`/${id}`); + } + + create(payload: CreateProcessDefinitionPayload): Promise { + return this.post('/', { payload }); + } + + update(id: string, payload: UpdateProcessDefinitionPayload): Promise { + return this.put(`/${id}`, { payload }); + } + + delete(id: string): Promise<{ id: string; count: number }> { + return this.del(`/${id}`); + } +} diff --git a/packages/client/src/store/TaskApi.ts b/packages/client/src/store/TaskApi.ts new file mode 100644 index 000000000..adb7e9e36 --- /dev/null +++ b/packages/client/src/store/TaskApi.ts @@ -0,0 +1,51 @@ +import { ApiTopic, ClientBase } from '@vertesia/api-fetch-client'; +import { + CompleteTaskPayload, + CreateTaskPayload, + ListTasksQuery, + Task, + UpdateTaskPayload, +} from '@vertesia/common'; + +export class TaskApi extends ApiTopic { + constructor(parent: ClientBase) { + super(parent, '/api/v1/tasks'); + } + + list(query?: ListTasksQuery): Promise { + const params: Record = {}; + if (query?.status) { + params.status = Array.isArray(query.status) ? query.status.join(',') : query.status; + } + if (query?.assignee) params.assignee = query.assignee; + if (query?.run_id) params.run_id = query.run_id; + if (query?.source_type) params.source_type = query.source_type; + if (query?.limit != null) params.limit = String(query.limit); + if (query?.offset != null) params.offset = String(query.offset); + return this.get('/', { query: params }); + } + + retrieve(id: string): Promise { + return this.get(`/${id}`); + } + + create(payload: CreateTaskPayload): Promise { + return this.post('/', { payload }); + } + + update(id: string, payload: UpdateTaskPayload): Promise { + return this.put(`/${id}`, { payload }); + } + + complete(id: string, payload: CompleteTaskPayload): Promise { + return this.post(`/${id}/complete`, { payload }); + } + + cancel(id: string): Promise { + return this.post(`/${id}/cancel`, {}); + } + + delete(id: string): Promise<{ id: string; count: number }> { + return this.del(`/${id}`); + } +} diff --git a/packages/client/src/store/client.ts b/packages/client/src/store/client.ts index f30a0c3dd..4f4121f79 100644 --- a/packages/client/src/store/client.ts +++ b/packages/client/src/store/client.ts @@ -13,9 +13,11 @@ import { FilesApi } from "./FilesApi.js"; import { HiveMemoryApi } from "./HiveMemoryApi.js"; import { ObjectsApi } from "./ObjectsApi.js"; import { PendingAsksApi } from "./PendingAsksApi.js"; +import { ProcessApi } from "./ProcessApi.js"; import { QueryApi } from "./QueryApi.js"; import { RenderingApi } from "./RenderingApi.js"; import { SchedulesApi } from "./SchedulesApi.js"; +import { TaskApi } from "./TaskApi.js"; import { ToolsApi } from "./ToolsApi.js"; import { TypesApi } from "./TypesApi.js"; import { VERSION, VERSION_HEADER } from "./version.js"; @@ -91,6 +93,8 @@ export class ZenoClient extends AbstractFetchClient { types = new TypesApi(this); workflows = new WorkflowsApi(this); schedules = new SchedulesApi(this); + processes = new ProcessApi(this); + tasks = new TaskApi(this); files = new FilesApi(this); commands = new CommandsApi(this); workers = new WorkersApi(this); diff --git a/packages/client/src/store/index.ts b/packages/client/src/store/index.ts index 4129fcfa3..9183f14d5 100644 --- a/packages/client/src/store/index.ts +++ b/packages/client/src/store/index.ts @@ -9,10 +9,11 @@ export * from "./HiveMemoryApi.js"; export * from "./IndexingApi.js"; export * from "./ObjectsApi.js"; export * from "./PendingAsksApi.js"; +export * from "./ProcessApi.js"; export * from "./QueryApi.js"; export * from "./RenderingApi.js"; export * from "./SchedulesApi.js"; +export * from "./TaskApi.js"; export * from "./TypeCatalogApi.js"; export * from "./TypesApi.js"; export * from "./WorkflowsApi.js"; - diff --git a/packages/common/src/access-control.ts b/packages/common/src/access-control.ts index 5a090ec41..dc6fe8c6d 100644 --- a/packages/common/src/access-control.ts +++ b/packages/common/src/access-control.ts @@ -46,6 +46,9 @@ export enum Permission { workflow_admin = "workflow:admin", workflow_superadmin = "workflow:superadmin", + task_read = "task:read", + task_manage = "task:manage", + iam_impersonate = "iam:impersonate", /** whether the user has access to Sutdio App. */ diff --git a/packages/common/src/store/agent-run.ts b/packages/common/src/store/agent-run.ts index 79d966a5d..26015d0c7 100644 --- a/packages/common/src/store/agent-run.ts +++ b/packages/common/src/store/agent-run.ts @@ -16,6 +16,7 @@ import { AgentSearchScope, ConversationVisibility, InteractionExecutionConfigura import { UserChannel } from "../email.js"; import { ContentObjectTypeRef } from "./store.js"; import { ConversationActivityState } from "./workflow.js"; +import { ProcessDefinitionBody, ProcessState } from "./process.js"; /** * Status of an agent run through its lifecycle. @@ -38,6 +39,90 @@ export type AgentRunArchiveState = 'none' | 'pending' | 'archiving' | 'complete' */ export type AgentRunType = 'api' | 'schedule'; +/** + * Internal discriminator key for documents stored in the agent_runs collection. + */ +export type RunKind = 'agent' | 'process'; + +/** + * Public-facing runtime mode. + */ +export type RunType = 'autonomous' | 'supervised' | 'programmatic'; + +/** + * Shared fields for all records stored in the agent_runs collection. + */ +export interface RunBase { + /** The stable identifier used by all client code */ + id: string; + + /** Internal discriminator key */ + run_kind: RunKind; + + /** Public-facing runtime mode */ + run_type: RunType; + + /** Account ID */ + account: string; + + /** Project ID */ + project: string; + + /** Temporal workflow ID (stable across continueAsNew) */ + workflow_id?: string; + + /** First Temporal workflow run ID (used for Redis channel and artifact resolution) */ + first_workflow_run_id?: string; + + /** Artifact storage path for this run */ + artifacts_path?: string; + + /** Current status of the run */ + status: AgentRunStatus; + + /** Whether the run is currently working or idle */ + activity_state?: ConversationActivityState; + + /** Conversation/process visibility */ + visibility?: ConversationVisibility; + + /** User or service that initiated the run */ + started_by: string; + + /** When the run started */ + started_at: Date; + + /** When the run completed (or failed/cancelled) */ + completed_at?: Date; + + /** Short human-readable title */ + title?: string; + + /** User-defined or system tags for categorization */ + tags?: string[]; + + /** Categories for organizing runs */ + categories?: string[]; + + /** How the run was started */ + source?: RunSource; + + /** Replacement for legacy AgentRun.type */ + source_type?: AgentRunType; + + /** Schedule ID — set when this run was triggered by a Temporal schedule */ + schedule_id?: string; + + /** Archive lifecycle state */ + archive_state?: AgentRunArchiveState; + + /** Timestamp when the document was created */ + created_at: Date; + + /** Timestamp when the document was last updated */ + updated_at: Date; +} + /** * Shared fields between CreateAgentRunPayload and AgentRun. * @@ -85,6 +170,11 @@ export interface AgentRunBase, TProperties = Record< schedule_id?: string; /** How the run was created */ + source_type?: AgentRunType; + + /** + * @deprecated Use source_type for creation source and run_type for runtime mode. + */ type?: AgentRunType; } @@ -97,15 +187,9 @@ export interface AgentRunBase, TProperties = Record< * @typeParam TData - The interaction's expected input data type. * @typeParam TProperties - The content type's property schema. */ -export interface AgentRun, TProperties = Record> extends AgentRunBase { - /** The stable identifier used by all client code */ - id: string; - - /** Account ID */ - account: string; - - /** Project ID */ - project: string; +export interface AgentRun, TProperties = Record> extends RunBase, AgentRunBase { + run_kind: 'agent'; + run_type: 'autonomous'; // --- Temporal workflow references --- @@ -164,16 +248,31 @@ export interface AgentRun, TProperties = Record, TProperties = Record> = AgentRun; +export type SupervisedRunResponse = ProcessRun & { run_type: 'supervised' }; +export type ProgrammaticRunResponse = ProcessRun & { run_type: 'programmatic' }; +export type AgentRunResponse, TProperties = Record> = + | AutonomousRunResponse + | SupervisedRunResponse + | ProgrammaticRunResponse; + /** * Payload to create and start a new agent run. * @@ -203,6 +302,19 @@ export interface CreateAgentRunPayload, TProperties started_by?: string; } +export interface CreateProcessRunPayload> { + process_id?: string; + process_definition?: ProcessDefinitionBody; + run_type: 'supervised' | 'programmatic'; + data?: TData; + config?: ProcessRunConfig; + visibility?: ConversationVisibility; + tags?: string[]; + categories?: string[]; + source?: RunSource; + started_by?: string; +} + /** * Filters for listing agent runs. */ @@ -234,6 +346,12 @@ export interface ListAgentRunsQuery { /** Filter by run type */ type?: AgentRunType; + /** Filter by public runtime mode */ + run_type?: RunType | RunType[]; + + /** Filter by internal run discriminator */ + run_kind?: RunKind; + /** Field to sort by */ sort?: 'started_at' | 'updated_at'; @@ -266,6 +384,9 @@ export interface SearchAgentRunsQuery { /** Filter by content type name */ content_type_name?: string; + /** Filter by public runtime mode */ + run_type?: RunType | RunType[]; + /** Only return runs started after this date */ since?: Date; @@ -287,7 +408,13 @@ export interface AgentRunSearchHit { score: number; /** Interaction ID */ - interaction: string; + interaction?: string; + + /** Public-facing runtime mode */ + run_type?: RunType; + + /** Internal run discriminator */ + run_kind?: RunKind; /** Human-readable interaction name */ interaction_name?: string; @@ -338,6 +465,11 @@ export interface AgentRunSearchHit { schedule_id?: string; /** How the run was created */ + source_type?: AgentRunType; + + /** + * @deprecated Use source_type for creation source and run_type for runtime mode. + */ type?: AgentRunType; /** Created timestamp */ @@ -368,10 +500,16 @@ export interface AgentRunInternals { first_workflow_run_id?: string; artifacts_path?: string; status: AgentRunStatus; - interaction: string; + run_kind?: RunKind; + run_type?: RunType; + interaction?: string; interaction_name?: string; config?: InteractionExecutionConfiguration; - interactive: boolean; + interactive?: boolean; + process_id?: string; + process_definition_snapshot?: ProcessDefinitionBody; + process_version?: number; + process_state?: ProcessState; started_at: Date; completed_at?: Date; started_by: string; diff --git a/packages/common/src/store/index.ts b/packages/common/src/store/index.ts index a78c3ac8b..247f2c1b2 100644 --- a/packages/common/src/store/index.ts +++ b/packages/common/src/store/index.ts @@ -7,11 +7,12 @@ export * from "./doc-analyzer.js"; export * from "./dsl-workflow.js"; export * from "./hive-memory.js"; export * from "./object-types.js"; +export * from "./process.js"; export * from "./schedule.js"; export * from "./signals.js"; export * from "./store.js"; +export * from "./task.js"; export * from "./rendering.js"; export * from "./temporalio.js"; export * from "./worker.js"; export * from "./workflow.js"; - diff --git a/packages/common/src/store/process.ts b/packages/common/src/store/process.ts new file mode 100644 index 000000000..73876d50e --- /dev/null +++ b/packages/common/src/store/process.ts @@ -0,0 +1,127 @@ +import { JSONSchema } from "../json-schema.js"; +import { TaskField } from "./task.js"; + +export type JsonLogicRule = Record; + +export type ProcessDefinitionStatus = 'draft' | 'published' | 'archived'; + +export type ProcessNodeType = + | 'tool' + | 'interaction' + | 'agent' + | 'human_task' + | 'parallel' + | 'condition' + | 'final'; + +export type TransitionTrigger = 'auto' | 'agent' | 'user'; + +export interface TransitionDefinition { + to: string; + guard?: JsonLogicRule; + trigger?: TransitionTrigger; + label?: string; +} + +export interface BranchDefinition { + to: string; + when?: JsonLogicRule; + default?: boolean; +} + +export interface HumanTaskDefinition { + title: string; + description?: string; + assignee?: string; + fields: TaskField[]; +} + +export interface NodeDefinition { + type: ProcessNodeType; + tool?: string; + interaction?: string; + prompt?: string; + input?: Record; + config?: Record; + title?: string; + description?: string; + writes?: string[]; + skippable?: boolean; + max_retries?: number; + transitions?: TransitionDefinition[]; + task?: HumanTaskDefinition; + foreach?: string; + as?: string; + node?: NodeDefinition; + collect?: string; + branches?: BranchDefinition[]; +} + +export interface ProcessContextDefinition { + schema: JSONSchema; + initial: Record; +} + +export interface ProcessDefinitionBody { + process: string; + description?: string; + initial: string; + model?: string; + context: ProcessContextDefinition; + nodes: Record; +} + +export interface ProcessDefinition { + id: string; + account: string; + project: string; + name: string; + description?: string; + status: ProcessDefinitionStatus; + version: number; + tags?: string[]; + definition: ProcessDefinitionBody; + created_at: Date; + updated_at: Date; + created_by: string; + updated_by: string; +} + +export interface NodeHistoryEntry { + node: string; + entered_at: Date; + exited_at?: Date; + status: 'running' | 'completed' | 'skipped' | 'failed'; + context_diff: Record; +} + +export interface ProcessState { + xstate_snapshot?: any; + context: Record; + current_node: string; + node_history: NodeHistoryEntry[]; + sequence: number; + _current_node?: string; + _previous_node?: string; + _transition_count?: number; + _node_entries?: Record; + _node_tool_calls?: Record; +} + +export interface CreateProcessDefinitionPayload { + name: string; + description?: string; + status?: ProcessDefinitionStatus; + version?: number; + tags?: string[]; + definition: ProcessDefinitionBody; +} + +export interface UpdateProcessDefinitionPayload { + name?: string; + description?: string; + status?: ProcessDefinitionStatus; + version?: number; + tags?: string[]; + definition?: ProcessDefinitionBody; +} diff --git a/packages/common/src/store/task.ts b/packages/common/src/store/task.ts new file mode 100644 index 000000000..48701128e --- /dev/null +++ b/packages/common/src/store/task.ts @@ -0,0 +1,70 @@ +/** + * Durable human task types used by process human_task nodes and agent asks. + */ + +export type DurableTaskStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled'; + +export type TaskFieldType = 'string' | 'number' | 'boolean' | 'select' | 'text'; + +export interface TaskField { + name: string; + type: TaskFieldType; + required?: boolean; + label?: string; + options?: string[]; + default?: any; +} + +export interface TaskSource { + type: 'process' | 'agent'; + run_id: string; + node?: string; +} + +export interface Task { + id: string; + account: string; + project: string; + title: string; + description?: string; + status: DurableTaskStatus; + assignee?: string; + fields: TaskField[]; + result?: Record; + source: TaskSource; + due_at?: Date; + created_at: Date; + completed_at?: Date; + updated_at?: Date; +} + +export interface CreateTaskPayload { + title: string; + description?: string; + assignee?: string; + fields?: TaskField[]; + source: TaskSource; + due_at?: Date; +} + +export interface UpdateTaskPayload { + title?: string; + description?: string; + status?: DurableTaskStatus; + assignee?: string | null; + fields?: TaskField[]; + due_at?: Date | null; +} + +export interface CompleteTaskPayload { + result: Record; +} + +export interface ListTasksQuery { + status?: DurableTaskStatus | DurableTaskStatus[]; + assignee?: string; + run_id?: string; + source_type?: TaskSource['type']; + limit?: number; + offset?: number; +} From e79b52416c786b81f30ec25e0ef76a852b2628fa Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Thu, 16 Apr 2026 03:31:08 +0900 Subject: [PATCH 02/75] feat: add process validation functionality and export from index --- packages/common/src/store/index.ts | 1 + .../common/src/store/process-validation.ts | 150 ++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 packages/common/src/store/process-validation.ts diff --git a/packages/common/src/store/index.ts b/packages/common/src/store/index.ts index 247f2c1b2..91dbc666a 100644 --- a/packages/common/src/store/index.ts +++ b/packages/common/src/store/index.ts @@ -8,6 +8,7 @@ export * from "./dsl-workflow.js"; export * from "./hive-memory.js"; export * from "./object-types.js"; export * from "./process.js"; +export * from "./process-validation.js"; export * from "./schedule.js"; export * from "./signals.js"; export * from "./store.js"; diff --git a/packages/common/src/store/process-validation.ts b/packages/common/src/store/process-validation.ts new file mode 100644 index 000000000..e6be22faf --- /dev/null +++ b/packages/common/src/store/process-validation.ts @@ -0,0 +1,150 @@ +import type { NodeDefinition, ProcessDefinitionBody } from "./process.js"; + +export interface ProcessDefinitionValidationResult { + valid: boolean; + errors: string[]; +} + +export const MAX_PROCESS_DEFINITION_BYTES = 1024 * 1024; +export const MAX_PROCESS_GUARD_DEPTH = 64; +export const MAX_PROCESS_GUARD_NODES = 4096; + +export function validateProcessDefinitionBody(definition: ProcessDefinitionBody): void { + const result = getProcessDefinitionValidationResult(definition); + if (!result.valid) { + throw new Error(result.errors.join("; ")); + } +} + +export function getProcessDefinitionValidationResult(definition: ProcessDefinitionBody): ProcessDefinitionValidationResult { + const errors: string[] = []; + const size = new TextEncoder().encode(JSON.stringify(definition)).length; + if (size > MAX_PROCESS_DEFINITION_BYTES) { + errors.push(`process definition exceeds ${MAX_PROCESS_DEFINITION_BYTES} bytes`); + } + + if (!definition.process) { + errors.push("process is missing"); + } + if (!definition.initial) { + errors.push("initial node is missing"); + } + if (!definition.nodes || Object.keys(definition.nodes).length === 0) { + errors.push("nodes are missing"); + } else if (definition.initial && !definition.nodes[definition.initial]) { + errors.push(`initial node "${definition.initial}" does not exist`); + } + if (!definition.context?.schema) { + errors.push("context.schema is missing"); + } + if (!definition.context?.initial) { + errors.push("context.initial is missing"); + } + + for (const [nodeId, node] of Object.entries(definition.nodes ?? {})) { + validateNodeDefinition(definition, nodeId, node, errors); + } + + return { + valid: errors.length === 0, + errors, + }; +} + +function validateNodeDefinition( + definition: ProcessDefinitionBody, + nodeId: string, + node: NodeDefinition, + errors: string[], +) { + if (!isProcessNodeType(node.type)) { + errors.push(`node "${nodeId}" has invalid type "${String(node.type)}"`); + } + if (node.type === "human_task") { + if (!node.task) { + errors.push(`human_task node "${nodeId}" is missing task`); + } else if (!node.task.title) { + errors.push(`human_task node "${nodeId}" task title is missing`); + } else if (!Array.isArray(node.task.fields)) { + errors.push(`human_task node "${nodeId}" task fields must be an array`); + } + } + if (node.type === "parallel" && node.node) { + validateNodeDefinition(definition, `${nodeId}.node`, node.node, errors); + } + for (const transition of node.transitions ?? []) { + if (!definition.nodes[transition.to]) { + errors.push(`node "${nodeId}" has transition to "${transition.to}" which does not exist`); + } + if (transition.trigger && !isTransitionTrigger(transition.trigger)) { + errors.push(`node "${nodeId}" has invalid transition trigger "${transition.trigger}"`); + } + if (transition.guard) { + validateGuardRule(`node "${nodeId}" transition to "${transition.to}" guard`, transition.guard, errors); + } + } + + for (const branch of node.branches ?? []) { + if (!definition.nodes[branch.to]) { + errors.push(`node "${nodeId}" has branch to "${branch.to}" which does not exist`); + } + if (branch.when) { + validateGuardRule(`node "${nodeId}" branch to "${branch.to}" guard`, branch.when, errors); + } + } +} + +function isProcessNodeType(value: string): boolean { + return value === "tool" + || value === "interaction" + || value === "agent" + || value === "human_task" + || value === "parallel" + || value === "condition" + || value === "final"; +} + +function isTransitionTrigger(value: string): boolean { + return value === "auto" || value === "agent" || value === "user"; +} + +function validateGuardRule(label: string, rule: unknown, errors: string[]) { + const result = inspectGuardRule(rule); + if (result.depth > MAX_PROCESS_GUARD_DEPTH) { + errors.push(`${label} exceeds maximum depth ${MAX_PROCESS_GUARD_DEPTH}`); + } + if (result.nodes > MAX_PROCESS_GUARD_NODES) { + errors.push(`${label} exceeds maximum node count ${MAX_PROCESS_GUARD_NODES}`); + } +} + +function inspectGuardRule(rule: unknown): { depth: number; nodes: number } { + let maxDepth = 0; + let nodes = 0; + const stack: { value: unknown; depth: number }[] = [{ value: rule, depth: 1 }]; + while (stack.length > 0) { + const next = stack.pop(); + if (!next) { + continue; + } + nodes += 1; + maxDepth = Math.max(maxDepth, next.depth); + if (nodes > MAX_PROCESS_GUARD_NODES || maxDepth > MAX_PROCESS_GUARD_DEPTH) { + break; + } + if (Array.isArray(next.value)) { + for (const item of next.value) { + stack.push({ value: item, depth: next.depth + 1 }); + } + } else if (isRecord(next.value)) { + for (const value of Object.values(next.value)) { + stack.push({ value, depth: next.depth + 1 }); + } + } + } + return { depth: maxDepth, nodes }; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} From 9c4d767beb56f1eb86bf1bde682adfc577b3f1a7 Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Fri, 17 Apr 2026 19:08:22 +0900 Subject: [PATCH 03/75] feat: enhance process validation and task handling with new interfaces and validation rules --- packages/client/src/store/PendingAsksApi.ts | 4 +- packages/common/src/pending-asks.ts | 7 + .../src/store/process-validation.test.ts | 142 ++++++++++++++++++ .../common/src/store/process-validation.ts | 17 +++ packages/common/src/store/process.ts | 3 + packages/common/src/store/task.ts | 1 + 6 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 packages/common/src/store/process-validation.test.ts diff --git a/packages/client/src/store/PendingAsksApi.ts b/packages/client/src/store/PendingAsksApi.ts index d1302e820..ec99a929f 100644 --- a/packages/client/src/store/PendingAsksApi.ts +++ b/packages/client/src/store/PendingAsksApi.ts @@ -1,5 +1,5 @@ import { ApiTopic, ClientBase } from "@vertesia/api-fetch-client"; -import { ListPendingAsksResponse, PendingAskData, UserChannel } from "@vertesia/common"; +import type { ListPendingAsksResponse, PendingAskData, TaskField, UserChannel } from "@vertesia/common"; /** * Request to register a pending ask. @@ -17,6 +17,8 @@ export interface RegisterPendingAskRequest { timeoutHours?: number; /** User communication channels */ userChannels: UserChannel[]; + /** Durable task fields shown in task inbox */ + taskFields?: TaskField[]; } /** diff --git a/packages/common/src/pending-asks.ts b/packages/common/src/pending-asks.ts index 63de0ce16..2f6ba857f 100644 --- a/packages/common/src/pending-asks.ts +++ b/packages/common/src/pending-asks.ts @@ -4,6 +4,7 @@ */ import { UserChannel } from "./email.js"; +import type { TaskField } from "./store/task.js"; // ================= Pending Ask Data ==================== @@ -42,6 +43,8 @@ export interface PendingAskData { expiresAt: number; /** Current status of the ask */ status: PendingAskStatus; + /** Durable task created for this ask_user request. */ + taskId?: string; /** Timestamp when resolved (ms since epoch) */ resolvedAt?: number; /** User's response (after resolution) */ @@ -104,3 +107,7 @@ export interface AskUserWebhookEvent { export interface ListPendingAsksResponse { asks: PendingAskData[]; } + +export interface AskUserTaskMetadata { + taskFields?: TaskField[]; +} diff --git a/packages/common/src/store/process-validation.test.ts b/packages/common/src/store/process-validation.test.ts new file mode 100644 index 000000000..75d695379 --- /dev/null +++ b/packages/common/src/store/process-validation.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vitest"; +import type { ProcessDefinitionBody } from "./process.js"; +import { + MAX_PROCESS_GUARD_DEPTH, + MAX_PROCESS_GUARD_NODES, + getProcessDefinitionValidationResult, + validateProcessDefinitionBody, +} from "./process-validation.js"; + +function validDefinition(): ProcessDefinitionBody { + return { + process: "approval", + initial: "review", + context: { + schema: { + type: "object", + properties: { + approved: { type: "boolean" }, + }, + required: ["approved"], + additionalProperties: false, + }, + initial: { approved: false }, + }, + nodes: { + review: { + type: "human_task", + task: { + title: "Review", + fields: [{ name: "approved", type: "boolean", required: true }], + }, + transitions: [{ to: "approved", trigger: "user" }], + }, + approved: { + type: "final", + }, + }, + }; +} + +describe("process definition validation", () => { + it("accepts a structurally valid definition", () => { + expect(() => validateProcessDefinitionBody(validDefinition())).not.toThrow(); + }); + + it("rejects missing transition targets", () => { + const definition = validDefinition(); + definition.nodes.review.transitions = [{ to: "missing" }]; + + expect(() => validateProcessDefinitionBody(definition)).toThrow( + 'node "review" has transition to "missing" which does not exist', + ); + }); + + it("rejects missing branch targets", () => { + const definition = validDefinition(); + definition.nodes.review.type = "condition"; + definition.nodes.review.task = undefined; + definition.nodes.review.transitions = undefined; + definition.nodes.review.branches = [{ to: "missing", default: true }]; + + expect(() => validateProcessDefinitionBody(definition)).toThrow( + 'node "review" has branch to "missing" which does not exist', + ); + }); + + it("rejects unsupported parallel child node types", () => { + const definition = validDefinition(); + definition.initial = "fanout"; + definition.context.schema = { + type: "object", + properties: { + items: { type: "array", items: { type: "string" } }, + }, + additionalProperties: true, + }; + definition.context.initial = { items: [] }; + definition.nodes = { + fanout: { + type: "parallel", + foreach: "items", + node: { + type: "human_task", + task: { + title: "Review", + fields: [], + }, + }, + transitions: [{ to: "approved" }], + }, + approved: { + type: "final", + }, + }; + + expect(() => validateProcessDefinitionBody(definition)).toThrow( + 'parallel node "fanout" has unsupported child node type "human_task"', + ); + }); + + it("reports all structural errors without importing runtime schema validators", () => { + const definition = { + process: "", + initial: "missing", + context: { + schema: {}, + initial: {}, + }, + nodes: { + review: { + type: "human_task", + }, + }, + } satisfies ProcessDefinitionBody; + + const result = getProcessDefinitionValidationResult(definition); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('initial node "missing" does not exist'); + expect(result.errors).toContain('human_task node "review" is missing task'); + }); + + it("rejects overly deep guard rules", () => { + const definition = validDefinition(); + let guard: Record = { var: "approved" }; + for (let index = 0; index < MAX_PROCESS_GUARD_DEPTH; index += 1) { + guard = { "!": [guard] }; + } + definition.nodes.review.transitions = [{ to: "approved", guard }]; + + expect(() => validateProcessDefinitionBody(definition)).toThrow("exceeds maximum depth"); + }); + + it("rejects overly large guard rules", () => { + const definition = validDefinition(); + definition.nodes.review.transitions = [ + { to: "approved", guard: { and: Array.from({ length: MAX_PROCESS_GUARD_NODES + 1 }, () => true) } }, + ]; + + expect(() => validateProcessDefinitionBody(definition)).toThrow("exceeds maximum node count"); + }); +}); diff --git a/packages/common/src/store/process-validation.ts b/packages/common/src/store/process-validation.ts index e6be22faf..856ae4b0e 100644 --- a/packages/common/src/store/process-validation.ts +++ b/packages/common/src/store/process-validation.ts @@ -70,8 +70,14 @@ function validateNodeDefinition( } } if (node.type === "parallel" && node.node) { + if (!isParallelChildNodeType(node.node.type)) { + errors.push(`parallel node "${nodeId}" has unsupported child node type "${String(node.node.type)}"`); + } validateNodeDefinition(definition, `${nodeId}.node`, node.node, errors); } + if (node.failure_policy && !isParallelFailurePolicy(node.failure_policy)) { + errors.push(`node "${nodeId}" has invalid failure_policy "${String(node.failure_policy)}"`); + } for (const transition of node.transitions ?? []) { if (!definition.nodes[transition.to]) { errors.push(`node "${nodeId}" has transition to "${transition.to}" which does not exist`); @@ -108,6 +114,17 @@ function isTransitionTrigger(value: string): boolean { return value === "auto" || value === "agent" || value === "user"; } +function isParallelFailurePolicy(value: string): boolean { + return value === "fail_fast" || value === "collect_errors"; +} + +function isParallelChildNodeType(value: string): boolean { + return value === "tool" + || value === "interaction" + || value === "agent" + || value === "condition"; +} + function validateGuardRule(label: string, rule: unknown, errors: string[]) { const result = inspectGuardRule(rule); if (result.depth > MAX_PROCESS_GUARD_DEPTH) { diff --git a/packages/common/src/store/process.ts b/packages/common/src/store/process.ts index 73876d50e..17076b64f 100644 --- a/packages/common/src/store/process.ts +++ b/packages/common/src/store/process.ts @@ -15,6 +15,7 @@ export type ProcessNodeType = | 'final'; export type TransitionTrigger = 'auto' | 'agent' | 'user'; +export type ParallelFailurePolicy = 'fail_fast' | 'collect_errors'; export interface TransitionDefinition { to: string; @@ -49,11 +50,13 @@ export interface NodeDefinition { skippable?: boolean; max_retries?: number; transitions?: TransitionDefinition[]; + tools?: string[]; task?: HumanTaskDefinition; foreach?: string; as?: string; node?: NodeDefinition; collect?: string; + failure_policy?: ParallelFailurePolicy; branches?: BranchDefinition[]; } diff --git a/packages/common/src/store/task.ts b/packages/common/src/store/task.ts index 48701128e..c1445831c 100644 --- a/packages/common/src/store/task.ts +++ b/packages/common/src/store/task.ts @@ -19,6 +19,7 @@ export interface TaskSource { type: 'process' | 'agent'; run_id: string; node?: string; + ask_id?: string; } export interface Task { From e8e4e544caf1ab95b5ddb0d165e9c98b70c66159 Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Fri, 17 Apr 2026 23:40:24 +0900 Subject: [PATCH 04/75] feat(common): add user_message to ProcessRunConfig Free-form context the user provides when starting a run. Surfaced to the orchestrator LLM in supervised mode (visible throughout the run) and persisted on the run regardless so programmatic runs retain the intent that triggered them. --- packages/common/src/store/agent-run.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/common/src/store/agent-run.ts b/packages/common/src/store/agent-run.ts index 26015d0c7..6f9d01984 100644 --- a/packages/common/src/store/agent-run.ts +++ b/packages/common/src/store/agent-run.ts @@ -252,6 +252,12 @@ export interface AgentRun, TProperties = Record Date: Sun, 19 Apr 2026 17:53:46 +0900 Subject: [PATCH 05/75] feat: enhance AppsApi and InteractionsApi with new system tool functionalities and delete interaction method; update common types for tool annotations and user confirmation --- llumiverse | 2 +- packages/client/src/AppsApi.ts | 15 ++- packages/client/src/InteractionsApi.ts | 1 + packages/client/src/store/AgentsApi.ts | 7 +- packages/common/src/apps.ts | 127 ++++++++++++++++++++++--- packages/common/src/interaction.ts | 9 ++ packages/common/src/store/process.ts | 43 ++++++++- packages/tools-sdk/src/ToolRegistry.ts | 2 + packages/tools-sdk/src/types.ts | 20 +++- 9 files changed, 204 insertions(+), 22 deletions(-) diff --git a/llumiverse b/llumiverse index 9cf0d091c..4a5bc6611 160000 --- a/llumiverse +++ b/llumiverse @@ -1 +1 @@ -Subproject commit 9cf0d091c807dc35e19d24595a42eca5a9b79f2b +Subproject commit 4a5bc6611fae911f040729feb606ae07dfb443e7 diff --git a/packages/client/src/AppsApi.ts b/packages/client/src/AppsApi.ts index f3b20f6ac..887251c44 100644 --- a/packages/client/src/AppsApi.ts +++ b/packages/client/src/AppsApi.ts @@ -1,5 +1,5 @@ import { ApiTopic, ClientBase, ServerError } from "@vertesia/api-fetch-client"; -import type { AppInstallation, AppInstallationKind, AppInstallationPayload, AppInstallationWithManifest, AppManifest, AppManifestData, AppToolCollection, ProjectRef, RequireAtLeastOne, ValidateUrlRequest, ValidateUrlResponse } from "@vertesia/common"; +import type { AppInstallation, AppInstallationKind, AppInstallationPayload, AppInstallationWithManifest, AppManifest, AppManifestData, AppPackage, AppToolCollection, ProjectRef, RequireAtLeastOne, ValidateUrlRequest, ValidateUrlResponse } from "@vertesia/common"; export interface OrphanedAppInstallation extends Omit { manifest: null, @@ -21,13 +21,22 @@ export default class AppsApi extends ApiTopic { /** * Get the list if tools provided by the given app. - * @param appId - * @returns + * @param appId + * @returns */ listAppInstallationTools(appInstallId: string): Promise { return this.get(`/installations/${appInstallId}/tools`) } + /** + * Fetch the always-on system tools package served by studio-server. + * Tools and skills (`learn_*`) are returned on separate fields so UIs can + * render them distinctly. URLs are already resolved per deployment. + */ + getSystemToolsPackage(scope: string = 'tools'): Promise { + return this.get('/system-tools/package', { query: { scope } }); + } + /** * @param ids - ids to filter by * @returns the app manifests but without the agent.tool property which can be big. diff --git a/packages/client/src/InteractionsApi.ts b/packages/client/src/InteractionsApi.ts index 9a64257e6..8c22ce557 100644 --- a/packages/client/src/InteractionsApi.ts +++ b/packages/client/src/InteractionsApi.ts @@ -46,6 +46,7 @@ export default class InteractionsApi extends ApiTopic { } }); } + /** * Find interactions given a mongo match query. * You can also specify if prompts schemas are included in the result diff --git a/packages/client/src/store/AgentsApi.ts b/packages/client/src/store/AgentsApi.ts index b12751ca5..439ccc02a 100644 --- a/packages/client/src/store/AgentsApi.ts +++ b/packages/client/src/store/AgentsApi.ts @@ -172,7 +172,12 @@ export class AgentsApi extends ApiTopic { return this.get(`/${id}/context`); } - getHistory(id: string): Promise<{ run_id: string; current_node: string; node_history: ProcessState['node_history'] }> { + getHistory(id: string): Promise<{ + run_id: string; + current_node: string; + node_history: ProcessState['node_history']; + node_history_ref?: ProcessState['node_history_ref']; + }> { return this.get(`/${id}/history`); } diff --git a/packages/common/src/apps.ts b/packages/common/src/apps.ts index 16afbdcdf..9bead6e09 100644 --- a/packages/common/src/apps.ts +++ b/packages/common/src/apps.ts @@ -156,16 +156,20 @@ export type ToolCollection = string | ToolCollectionObject; /** * Normalizes a tool collection to the object format. - * Handles backward compatibility with string URLs. + * Handles backward compatibility with string URLs and applies optional + * `{{var}}` substitution to the URL so legacy manifests can reference + * deployment-time variables like `{{studio_ui}}`. * * @param collection - String URL or ToolCollectionObject + * @param vars - Optional endpoint variables to substitute in URLs * @returns Normalized ToolCollectionObject */ -export function normalizeToolCollection(collection: ToolCollection): ToolCollectionObject { +export function normalizeToolCollection(collection: ToolCollection, vars?: Endpoints): ToolCollectionObject { if (typeof collection === 'string') { + const substituted = substituteEndpoints(collection, vars); // Legacy string format - if (collection.startsWith('mcp:')) { - const url = collection.substring('mcp:'.length); + if (substituted.startsWith('mcp:')) { + const url = substituted.substring('mcp:'.length); // For legacy MCP strings, derive name and prefix from URL const urlObj = new URL(url); const name = urlObj.hostname.replace(/\./g, '-'); @@ -178,11 +182,17 @@ export function normalizeToolCollection(collection: ToolCollection): ToolCollect }; } return { - url: collection, + url: substituted, type: 'vertesia_sdk' }; } - // Already in object format + // Already in object format — substitute URL if needed + if (vars && collection.url) { + const substituted = substituteEndpoints(collection.url, vars); + if (substituted !== collection.url) { + return { ...collection, url: substituted }; + } + } return collection; } @@ -232,6 +242,16 @@ export interface AgentToolDefinition extends ToolDefinition { * MCP tool annotations providing hints about tool behavior and safety. */ annotations?: MCPToolAnnotations; + /** + * When true, agents must obtain explicit user confirmation via `ask_user` + * (Yes/No) before invoking this tool. If the user answers No, the tool + * must not run and should return an error indicating the user declined. + * + * Stronger than `annotations.destructiveHint` (which is only a hint) — + * this is a hard contract the agent is expected to honor. Set on tools + * that perform irreversible or destructive actions (e.g. delete_*). + */ + requires_user_confirmation?: boolean; } /** @@ -366,17 +386,92 @@ export function isValidEndpointOverrideEnv(envName: string): boolean { } /** - * Resolves the effective endpoint for an app given an optional environment name. - * Returns the override endpoint if the env name matches a valid dev environment, otherwise the default endpoint. + * Deployment-time URL endpoints that can be referenced in app manifest URLs + * via `{{key}}` placeholders. The caller (typically studio-server) supplies + * these from environment config so that system apps can ship a single manifest + * with endpoints like `{{studio}}/api/package` that resolve per deployment. + */ +export interface Endpoints { + /** The Studio API (studio-server) base URL */ + studio?: string; + /** The Store API (zeno-server) base URL */ + store?: string; + /** The token server base URL */ + token?: string; + /** The browser-facing Studio UI (composable-ui) base URL */ + ui?: string; +} + +/** + * Substitutes `{{key}}` placeholders in a URL with the matching endpoint. + * Unknown placeholders are left untouched (so failures surface as fetch errors + * with the unresolved placeholder visible, rather than silently pointing nowhere). + * Trailing slashes on replacement values are stripped to avoid `//api/...` joins. + */ +export function substituteEndpoints(url: string, endpoints?: Endpoints): string { + if (!url || !endpoints) return url; + return url.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, key: string) => { + const value = (endpoints as Record)[key]; + if (typeof value !== 'string' || !value) return match; + return value.replace(/\/+$/, ''); + }); +} + +/** + * Resolves the effective endpoint for an app given an optional environment name + * and deployment-time URL variables. + * + * Order of resolution: + * 1. If `envName` matches a dev-only endpoint override key, use that URL + * 2. Otherwise use the main `endpoint` + * 3. Apply `{{var}}` substitution using `vars` */ export function resolveAppEndpoint( manifest: Pick, - envName?: string + envName?: string, + vars?: Endpoints ): string | undefined { - if (envName && manifest.endpoint_overrides?.[envName] && isValidEndpointOverrideEnv(envName)) { - return manifest.endpoint_overrides[envName]; + const raw = envName && manifest.endpoint_overrides?.[envName] && isValidEndpointOverrideEnv(envName) + ? manifest.endpoint_overrides[envName] + : manifest.endpoint; + return raw ? substituteEndpoints(raw, vars) : raw; +} + +/** + * Resolves all URL placeholders in a manifest in place (both `endpoint` and legacy + * `tool_collections[].url`). Intended for server-side serialization — clients and + * downstream workers receive already-substituted URLs so they don't need to know + * about deployment-time vars. + * + * Mutates the manifest rather than returning a copy so it works cleanly with + * Mongoose populated subdocs. + */ +export function resolveManifestUrls( + manifest: Partial | null | undefined, + envName?: string, + vars?: Endpoints +): void { + if (!manifest) return; + + if (manifest.endpoint) { + const resolved = resolveAppEndpoint(manifest, envName, vars); + if (resolved && resolved !== manifest.endpoint) { + manifest.endpoint = resolved; + } + } + + if (manifest.tool_collections && Array.isArray(manifest.tool_collections)) { + for (let i = 0; i < manifest.tool_collections.length; i++) { + const item = manifest.tool_collections[i]; + if (typeof item === 'string') { + const sub = substituteEndpoints(item, vars); + if (sub !== item) manifest.tool_collections[i] = sub; + } else if (item && typeof item === 'object' && item.url) { + const sub = substituteEndpoints(item.url, vars); + if (sub !== item.url) item.url = sub; + } + } } - return manifest.endpoint; } export type AppPackageScope = 'ui' | 'tools' | 'interactions' | 'types' | 'templates' | 'settings' | 'widgets' | 'activities' | 'all'; @@ -391,6 +486,14 @@ export interface AppPackage { */ tools?: AgentToolDefinition[] + /** + * A list of skills (`learn_*` tools) exposed by the app. Kept separate from + * `tools` so clients can render them distinctly — consumers that don't care + * (e.g. the worker building a combined tool registry) should concatenate + * the two lists. + */ + skills?: AgentToolDefinition[] + /** * A list of interactions exposed by the app */ diff --git a/packages/common/src/interaction.ts b/packages/common/src/interaction.ts index 35df7ffb0..6e287bc78 100644 --- a/packages/common/src/interaction.ts +++ b/packages/common/src/interaction.ts @@ -1208,6 +1208,15 @@ export interface ResolvedInteractionExecutionInfo { */ tags: string[]; + /** + * Agent runner configuration (tool_names opt-ins, is_agent, is_tool, etc.). + * Included on resolve so non-UI callers (worker activities) can pick up the + * interaction's defaults without a second retrieve round-trip — and so + * in-code interactions (sys:, app:) which have no Mongo document work the + * same as stored ones. + */ + agent_runner_options?: AgentRunnerOptions; + /** * The resolved runtime configuration */ diff --git a/packages/common/src/store/process.ts b/packages/common/src/store/process.ts index 17076b64f..6a28d2766 100644 --- a/packages/common/src/store/process.ts +++ b/packages/common/src/store/process.ts @@ -33,6 +33,12 @@ export interface BranchDefinition { export interface HumanTaskDefinition { title: string; description?: string; + /** + * Who owns the task. Either a group reference (`group:`) or a + * concrete user id. Leave unset to make the task available to anyone + * who can see the inbox. `role:` is not supported — use + * `group:` instead. + */ assignee?: string; fields: TaskField[]; } @@ -46,11 +52,26 @@ export interface NodeDefinition { config?: Record; title?: string; description?: string; + /** + * End-user-facing explanation of what this node does. Authored by the + * process designer (often an LLM) in plain language — one or two + * sentences — and rendered in run observability so a human reading the + * run can understand why this node exists without reading the config. + * Distinct from `description`, which is developer-facing. + */ + human_description?: string; writes?: string[]; skippable?: boolean; max_retries?: number; transitions?: TransitionDefinition[]; tools?: string[]; + /** + * Model id override for this node. If unset, falls back to the process + * run's `config.model`, then to the project's default. Useful when a + * specific node needs heavier reasoning (e.g. Opus for legal flagging) + * while the rest of the process uses a cheaper default. + */ + model?: string; task?: HumanTaskDefinition; foreach?: string; as?: string; @@ -91,11 +112,28 @@ export interface ProcessDefinition { } export interface NodeHistoryEntry { + id?: string; node: string; - entered_at: Date; - exited_at?: Date; + attempt?: number; + entered_at: Date | string; + exited_at?: Date | string; status: 'running' | 'completed' | 'skipped' | 'failed'; context_diff: Record; + data_ref?: string; + sequence?: number; +} + +export interface ProcessHistoryRef { + path: string; + latest_sequence: number; + count: number; +} + +export interface ProcessHistoryCheckpoint { + sequence: number; + current_node: string; + written_at: Date | string; + entries: NodeHistoryEntry[]; } export interface ProcessState { @@ -103,6 +141,7 @@ export interface ProcessState { context: Record; current_node: string; node_history: NodeHistoryEntry[]; + node_history_ref?: ProcessHistoryRef; sequence: number; _current_node?: string; _previous_node?: string; diff --git a/packages/tools-sdk/src/ToolRegistry.ts b/packages/tools-sdk/src/ToolRegistry.ts index 8d64ec6b1..478e98889 100644 --- a/packages/tools-sdk/src/ToolRegistry.ts +++ b/packages/tools-sdk/src/ToolRegistry.ts @@ -29,6 +29,8 @@ export class ToolRegistry { input_schema: tool.input_schema, category: this.category, default: tool.default, + ...(tool.annotations ? { annotations: tool.annotations } : {}), + ...(tool.requires_user_confirmation ? { requires_user_confirmation: true } : {}), }); let tools = Object.values(this.registry); if (context) { diff --git a/packages/tools-sdk/src/types.ts b/packages/tools-sdk/src/types.ts index 13adfcf7e..55e7a1452 100644 --- a/packages/tools-sdk/src/types.ts +++ b/packages/tools-sdk/src/types.ts @@ -1,6 +1,6 @@ import type { ToolDefinition, ToolUse } from "@llumiverse/common"; import { VertesiaClient } from "@vertesia/client"; -import { AgentToolDefinition, AuthTokenPayload, ProjectConfiguration, RenderingTemplateDefinition, ToolExecutionMetadata, ToolResult, ToolResultContent } from "@vertesia/common"; +import { AgentToolDefinition, AuthTokenPayload, MCPToolAnnotations, ProjectConfiguration, RenderingTemplateDefinition, ToolExecutionMetadata, ToolResult, ToolResultContent } from "@vertesia/common"; export type { ToolExecutionMetadata }; @@ -105,12 +105,26 @@ export interface Tool> extends ToolDefinitio */ default?: boolean; + /** + * MCP-style annotations (destructiveHint, readOnlyHint, etc). Propagated + * into the AgentToolDefinition on catalog / package responses. + */ + annotations?: MCPToolAnnotations; + + /** + * When true, agents must obtain explicit user confirmation via `ask_user` + * (Yes/No) before invoking this tool. If the user answers No, the tool + * must not run. Stronger than `annotations.destructiveHint` — this is a + * hard contract. + */ + requires_user_confirmation?: boolean; + /** * Optional filter to check if the tool is enabled for the given project configuration. * This can be used to dynamically enable/disable tools based on project settings, environment variables, or any other logic. * If no filter is provided, the tool will be enabled by default. - * @param payload - * @returns + * @param payload + * @returns */ isEnabled?: (payload: ToolUseContext) => boolean; } From 3c93b185e19b76913fc0853eb2528e32027b4601 Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Sun, 19 Apr 2026 18:40:01 +0900 Subject: [PATCH 06/75] feat: rename 'related_tools' to 'tools' across documentation and codebase for consistency --- packages/build-tools/README.md | 6 +++--- packages/build-tools/src/presets/skill.ts | 14 +++++--------- packages/build-tools/tests/skill.test.ts | 8 ++++---- packages/common/src/apps.ts | 9 +++++---- packages/common/src/interaction.ts | 4 +--- packages/common/src/skill.ts | 2 +- packages/common/src/store/conversation-state.ts | 2 +- packages/common/src/utils/schemas.ts | 2 +- .../tools-admin-ui/src/pages/SkillCollection.tsx | 6 +++--- packages/tools-admin-ui/src/pages/SkillDetail.tsx | 6 +++--- packages/tools-sdk/src/SkillCollection.ts | 10 +++++----- packages/tools-sdk/src/site/templates.ts | 6 +++--- packages/tools-sdk/src/types.ts | 7 ++++--- 13 files changed, 39 insertions(+), 43 deletions(-) diff --git a/packages/build-tools/README.md b/packages/build-tools/README.md index 2d9d04f81..02b6e939e 100644 --- a/packages/build-tools/README.md +++ b/packages/build-tools/README.md @@ -76,7 +76,7 @@ description: A helpful skill content_type: md context_triggers: keywords: [skill, helper] -related_tools: [tool1, tool2] +tools: [tool1, tool2] --- # My Skill @@ -95,7 +95,7 @@ This is the skill content in markdown. context_triggers: { keywords: ['skill', 'helper'] }, - related_tools: ['tool1', 'tool2'], + tools: ['tool1', 'tool2'], scripts: ['helper.js', 'script.py'], // If .js/.py files exist in skill dir widgets: ['chart', 'user-select'] // If .tsx files exist in skill dir } @@ -133,7 +133,7 @@ This is the skill content in markdown. system_packages?: string[]; // System-level packages template?: string; // Code template }; - related_tools?: string[]; // Optional: Related tool names + tools?: string[]; // Optional: Related tool names scripts?: string[]; // Optional: Script files in skill dir widgets?: string[]; // Optional: Widget names in skill dir isEnabled?: (context: any) => Promise; // Optional: Runtime filter function diff --git a/packages/build-tools/src/presets/skill.ts b/packages/build-tools/src/presets/skill.ts index 3c6bcb60b..850a13bdb 100644 --- a/packages/build-tools/src/presets/skill.ts +++ b/packages/build-tools/src/presets/skill.ts @@ -77,7 +77,6 @@ const SkillFrontmatterSchema = z.object({ // Nested structure fields context_triggers: SkillContextTriggersFrontmatterSchema.optional(), execution: SkillExecutionFrontmatterSchema.optional(), - related_tools: z.array(z.string()).optional(), input_schema: z.object({ type: z.literal('object'), properties: z.record(z.any()).optional(), @@ -111,7 +110,7 @@ export const SkillDefinitionSchema = z.object({ }).optional(), context_triggers: SkillContextTriggersSchema, execution: SkillExecutionSchema, - related_tools: z.array(z.string()).optional(), + tools: z.array(z.string()).optional(), scripts: z.array(z.string()).optional(), widgets: z.array(z.string()).optional() }).passthrough(); @@ -151,7 +150,7 @@ export type SkillDefinition = z.infer; * execution: * language: python * packages: [...] - * related_tools: [...] + * tools: [...] * * @param frontmatter - Parsed frontmatter object * @param instructions - Markdown content (body of the file) @@ -211,12 +210,9 @@ function buildSkillDefinition( } } - // Related tools - support both direct field and from tools field - if (frontmatter.related_tools) { - skill.related_tools = frontmatter.related_tools; - } else if (frontmatter.tools && !hasNestedTriggers) { - // If tools is not part of context_triggers, use it as related_tools - skill.related_tools = frontmatter.tools; + // Tools unlocked by this skill (from frontmatter `tools:` key) + if (frontmatter.tools) { + skill.tools = frontmatter.tools; } // Input schema from frontmatter diff --git a/packages/build-tools/tests/skill.test.ts b/packages/build-tools/tests/skill.test.ts index 682bad37d..076761f4f 100644 --- a/packages/build-tools/tests/skill.test.ts +++ b/packages/build-tools/tests/skill.test.ts @@ -43,18 +43,18 @@ Content here`; }); }); - it('should include related_tools from frontmatter', async () => { + it('should include tools from frontmatter', async () => { const content = `--- name: test title: Test description: Test description -related_tools: [tool1, tool2] +tools: [tool1, tool2] --- Content`; const result = await skillTransformer.transform(content, 'test.md'); - expect(result.data).toHaveProperty('related_tools'); - expect((result.data as any).related_tools).toEqual(['tool1', 'tool2']); + expect(result.data).toHaveProperty('tools'); + expect((result.data as any).tools).toEqual(['tool1', 'tool2']); }); it('should validate against schema successfully', () => { diff --git a/packages/common/src/apps.ts b/packages/common/src/apps.ts index 9bead6e09..3678b0491 100644 --- a/packages/common/src/apps.ts +++ b/packages/common/src/apps.ts @@ -230,14 +230,15 @@ export interface AgentToolDefinition extends ToolDefinition { /** * Whether this tool is available by default. * - true/undefined: Tool is always available to agents - * - false: Tool is only available when activated by a skill's related_tools + * - false: Tool is only available when enabled by a skill via `tools` */ default?: boolean; /** - * For skill tools (learn_*): list of related tool names that become available - * when this skill is called. Used for dynamic tool discovery. + * For skill tools (`learn_*`): the tool names this skill enables when called. + * Matches the `tools:` key used in SKILL.md frontmatter and built-in skill + * definitions — one name across the whole stack. */ - related_tools?: string[]; + tools?: string[]; /** * MCP tool annotations providing hints about tool behavior and safety. */ diff --git a/packages/common/src/interaction.ts b/packages/common/src/interaction.ts index 6e287bc78..87faee9d3 100644 --- a/packages/common/src/interaction.ts +++ b/packages/common/src/interaction.ts @@ -1268,10 +1268,8 @@ export interface SystemSkillCatalogEntry { title: string; /** Description of what the skill unlocks */ description: string; - /** Tools that become available when this skill is called */ + /** Tool names this skill enables (unlocks) when called */ tools: string[]; - /** Related tools that complement this skill */ - related_tools: string[]; } /** diff --git a/packages/common/src/skill.ts b/packages/common/src/skill.ts index 7bac724e3..a0d989b87 100644 --- a/packages/common/src/skill.ts +++ b/packages/common/src/skill.ts @@ -62,7 +62,7 @@ export interface InjectedSkill { /** * Tools related to this skill */ - related_tools?: string[]; + tools?: string[]; /** * UI module for rendering results diff --git a/packages/common/src/store/conversation-state.ts b/packages/common/src/store/conversation-state.ts index 23b9f8852..8e5f090ee 100644 --- a/packages/common/src/store/conversation-state.ts +++ b/packages/common/src/store/conversation-state.ts @@ -112,7 +112,7 @@ export interface ConversationState { /** * Tools that have been unlocked by skills during the conversation. * These tools were initially hidden (default: false) but became available - * when a skill with related_tools was called. + * when a skill with tools was called. */ unlocked_tools?: string[]; diff --git a/packages/common/src/utils/schemas.ts b/packages/common/src/utils/schemas.ts index 5464f5162..ea415d0d5 100644 --- a/packages/common/src/utils/schemas.ts +++ b/packages/common/src/utils/schemas.ts @@ -5,7 +5,7 @@ import { ExecutablePromptSegmentDef, PromptSegmentDefType } from "../prompt.js"; /** * Sanitize a tool definition to only include fields expected by LLM APIs. - * Removes extra fields like 'category', 'default', 'related_tools' that are + * Removes extra fields like 'category', 'default', 'tools' that are * used internally but should not be sent to the LLM. */ export function sanitizeToolDefinition(tool: ToolDefinition): ToolDefinition { diff --git a/packages/tools-admin-ui/src/pages/SkillCollection.tsx b/packages/tools-admin-ui/src/pages/SkillCollection.tsx index b417ea14c..5a75e2f41 100644 --- a/packages/tools-admin-ui/src/pages/SkillCollection.tsx +++ b/packages/tools-admin-ui/src/pages/SkillCollection.tsx @@ -9,7 +9,7 @@ import { TYPE_VARIANTS } from '../components/typeVariants.js'; interface SkillToolDef { name: string; description?: string; - related_tools?: string[]; + tools?: string[]; } interface SkillCollectionResponse { @@ -92,9 +92,9 @@ export function SkillCollection() {
{displayName}
{skill.description || 'No description'}
- {skill.related_tools && skill.related_tools.length > 0 && ( + {skill.tools && skill.tools.length > 0 && (
- {skill.related_tools.map(t => {t})} + {skill.tools.map(t => {t})}
)} diff --git a/packages/tools-admin-ui/src/pages/SkillDetail.tsx b/packages/tools-admin-ui/src/pages/SkillDetail.tsx index 03097c956..f73a6feff 100644 --- a/packages/tools-admin-ui/src/pages/SkillDetail.tsx +++ b/packages/tools-admin-ui/src/pages/SkillDetail.tsx @@ -11,7 +11,7 @@ interface SkillDefinitionResponse { instructions: string; content_type: 'md' | 'jst'; input_schema?: Record; - related_tools?: string[]; + tools?: string[]; execution?: { language: string; packages?: string[]; @@ -62,11 +62,11 @@ export function SkillDetail() { )} - {skill.related_tools && skill.related_tools.length > 0 && ( + {skill.tools && skill.tools.length > 0 && (

Related Tools

- {skill.related_tools.map(t => {t})} + {skill.tools.map(t => {t})}
)} diff --git a/packages/tools-sdk/src/SkillCollection.ts b/packages/tools-sdk/src/SkillCollection.ts index bb9507714..4abcc18bf 100644 --- a/packages/tools-sdk/src/SkillCollection.ts +++ b/packages/tools-sdk/src/SkillCollection.ts @@ -86,7 +86,7 @@ export class SkillCollection implements ICollection { * Get skills exposed as tool definitions. * This allows skills to appear alongside regular tools. * When called, they return rendered instructions. - * Includes related_tools for dynamic tool discovery. + * Includes tools for dynamic tool discovery. */ getToolDefinitions(filterContext?: ToolUseContext): AgentToolDefinition[] { const defaultSchema: ToolDefinition['input_schema'] = { @@ -109,8 +109,8 @@ export class SkillCollection implements ICollection { return skills.map(skill => { // Build description with related tools info if available let description = `[Skill] ${skill.description}. Returns contextual instructions for this task.`; - if (skill.related_tools && skill.related_tools.length > 0) { - description += ` Unlocks tools: ${skill.related_tools.join(', ')}.`; + if (skill.tools && skill.tools.length > 0) { + description += ` Unlocks tools: ${skill.tools.join(', ')}.`; } return { @@ -118,7 +118,7 @@ export class SkillCollection implements ICollection { name: `learn_${skill.name}`, description, input_schema: skill.input_schema || defaultSchema, - related_tools: skill.related_tools, + tools: skill.tools, category: this.name, }; }); @@ -357,7 +357,7 @@ export function parseSkillFile( // Related tools from frontmatter if (frontmatter.tools) { - skill.related_tools = frontmatter.tools; + skill.tools = frontmatter.tools; } return skill; diff --git a/packages/tools-sdk/src/site/templates.ts b/packages/tools-sdk/src/site/templates.ts index 22af39463..5968b8513 100644 --- a/packages/tools-sdk/src/site/templates.ts +++ b/packages/tools-sdk/src/site/templates.ts @@ -530,7 +530,7 @@ export function skillDetailCard(skill: SkillDefinition, collection: SkillCollect const hasKeywords = skill.context_triggers?.keywords?.length; const hasPackages = skill.execution?.packages?.length; const hasScripts = skill.scripts?.length; - const hasRelatedTools = skill.related_tools?.length; + const hasRelatedTools = skill.tools?.length; return /*html*/`
@@ -543,7 +543,7 @@ export function skillDetailCard(skill: SkillDefinition, collection: SkillCollect
Skill ${skill.execution?.language ? `${skill.execution.language}` : ''} - ${hasRelatedTools ? `Unlocks ${skill.related_tools?.length} tool${skill.related_tools?.length !== 1 ? 's' : ''}` : ''} + ${hasRelatedTools ? `Unlocks ${skill.tools?.length} tool${skill.tools?.length !== 1 ? 's' : ''}` : ''}
@@ -569,7 +569,7 @@ export function skillDetailCard(skill: SkillDefinition, collection: SkillCollect

Unlocks Tools

These tools become available when this skill is activated:

- ${skill.related_tools?.map(tool => `${tool}`).join('')} + ${skill.tools?.map(tool => `${tool}`).join('')}
` : ''} diff --git a/packages/tools-sdk/src/types.ts b/packages/tools-sdk/src/types.ts index 55e7a1452..aa807b264 100644 --- a/packages/tools-sdk/src/types.ts +++ b/packages/tools-sdk/src/types.ts @@ -101,7 +101,7 @@ export interface Tool> extends ToolDefinitio /** * Whether this tool is available by default. * - true/undefined: Tool is always available to agents - * - false: Tool is only available when activated by a skill's related_tools + * - false: Tool is only available when activated by a skill's tools */ default?: boolean; @@ -260,9 +260,10 @@ export interface SkillDefinition { */ execution?: SkillExecution; /** - * Related tools that work well with this skill + * Tool names this skill enables (unlocks) when called. Matches the + * `tools:` key used in SKILL.md frontmatter. */ - related_tools?: string[]; + tools?: string[]; /** * Scripts bundled with this skill (synced to sandbox when skill is used) */ From c03ea020147b0a63710fc560f5515cf4aebdec0e Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Mon, 20 Apr 2026 01:54:17 +0900 Subject: [PATCH 07/75] feat: enhance AgentsApi with new run recording methods and validation; update process validation logic and tests; add process node types and definitions --- packages/client/src/store/AgentsApi.ts | 33 ++++--- packages/common/src/apps.ts | 12 +++ packages/common/src/store/agent-run.ts | 56 ++++++++++- .../src/store/process-validation.test.ts | 93 +++++++++++++++++++ .../common/src/store/process-validation.ts | 93 ++++++++++++++++++- packages/common/src/store/process.ts | 50 +++++++++- packages/common/src/user.ts | 2 +- 7 files changed, 317 insertions(+), 22 deletions(-) diff --git a/packages/client/src/store/AgentsApi.ts b/packages/client/src/store/AgentsApi.ts index 439ccc02a..8acc2cd0b 100644 --- a/packages/client/src/store/AgentsApi.ts +++ b/packages/client/src/store/AgentsApi.ts @@ -8,6 +8,7 @@ import { AgentRunArchiveState, AgentRunInternals, AgentRunStatus, + BindRunWorkflowPayload, CompactMessage, ConversationActivityState, CreateAgentRunPayload, @@ -21,6 +22,9 @@ import { PromptSizeAnalyticsResponse, ProcessRun, ProcessState, + RecordAgentRunPayload, + RecordProcessRunPayload, + RecordRunPayload, RunsByAgentAnalyticsResponse, SearchAgentRunsQuery, SearchAgentRunsResponse, @@ -66,18 +70,14 @@ export class AgentsApi extends ApiTopic { } /** - * Record an AgentRun for an already-running workflow (e.g. schedule-triggered). - * Only creates the MongoDB document — the workflow passes its own Temporal IDs. + * Record a run for an already-running workflow. This only creates the + * MongoDB document; the caller owns the Temporal workflow ids. + * + * @internal */ - createRecord(payload: { - interaction: string; - schedule_id?: string; - workflow_id: string; - first_workflow_run_id: string; - visibility?: string; - data?: Record; - type?: string; - }): Promise { + recordRun>(payload: RecordAgentRunPayload): Promise>; + recordRun>(payload: RecordProcessRunPayload): Promise; + recordRun>(payload: RecordRunPayload): Promise | ProcessRun> { return this.post('/record', { payload }); } @@ -215,10 +215,19 @@ export class AgentsApi extends ApiTopic { sequence?: number; process_state?: ProcessState; }, - ): Promise { + ): Promise { return this.post(`/${id}/status`, { payload: update }); } + /** + * Attach Temporal workflow run ids to a pre-created run record. + * + * @internal + */ + bindWorkflowRun(id: string, payload: BindRunWorkflowPayload): Promise { + return this.post(`/${id}/workflow`, { payload }); + } + // ======================================================================== // Communication // ======================================================================== diff --git a/packages/common/src/apps.ts b/packages/common/src/apps.ts index 3678b0491..9cd514724 100644 --- a/packages/common/src/apps.ts +++ b/packages/common/src/apps.ts @@ -376,6 +376,18 @@ export interface AppManifestData { * Only dev environment names are allowed as keys (starting with "desktop-" or "dev-"). */ endpoint_overrides?: Record; + + /** + * Optional app version string (e.g. "1.0.0") — informational. + */ + version?: string; + + /** + * Free-form tags used for classification and filtering. Platform apps + * carry `"system"` so UIs can skip install/uninstall/manage-permission + * controls that don't apply to synthetic installations. + */ + tags?: string[]; } /** diff --git a/packages/common/src/store/agent-run.ts b/packages/common/src/store/agent-run.ts index 6f9d01984..a67f8f776 100644 --- a/packages/common/src/store/agent-run.ts +++ b/packages/common/src/store/agent-run.ts @@ -48,6 +48,7 @@ export type RunKind = 'agent' | 'process'; * Public-facing runtime mode. */ export type RunType = 'autonomous' | 'supervised' | 'programmatic'; +export type ProcessRunType = 'supervised' | 'programmatic'; /** * Shared fields for all records stored in the agent_runs collection. @@ -262,7 +263,7 @@ export interface ProcessRunConfig { export interface ProcessRun extends RunBase { run_kind: 'process'; - run_type: 'supervised' | 'programmatic'; + run_type: ProcessRunType; process_id?: string; process_definition_snapshot: ProcessDefinitionBody; process_version?: number; @@ -308,19 +309,66 @@ export interface CreateAgentRunPayload, TProperties started_by?: string; } -export interface CreateProcessRunPayload> { +export interface ProcessRunInputPayload, TSource = RunSource> { process_id?: string; process_definition?: ProcessDefinitionBody; - run_type: 'supervised' | 'programmatic'; data?: TData; config?: ProcessRunConfig; visibility?: ConversationVisibility; tags?: string[]; categories?: string[]; - source?: RunSource; + source?: TSource; started_by?: string; } +export interface CreateProcessRunPayload, TSource = RunSource> extends ProcessRunInputPayload { + run_type: ProcessRunType; +} + +export interface RecordRunWorkflowPayload { + /** Temporal workflow id. */ + workflow_id: string; + /** First Temporal run id for this workflow. Required when the workflow has already started. */ + first_workflow_run_id?: string; +} + +/** + * @internal Used by workflow activities that need to create a stable run + * document for a workflow they already own. + */ +export interface RecordAgentRunPayload> extends RecordRunWorkflowPayload { + run_kind?: 'agent'; + interaction: string; + first_workflow_run_id: string; + schedule_id?: string; + visibility?: ConversationVisibility; + data?: TData; + type?: AgentRunType; +} + +/** + * @internal Used by process workflows to reserve a child ProcessRun before + * starting its Temporal child workflow. + */ +export interface RecordProcessRunPayload, TSource = RunSource> + extends ProcessRunInputPayload, RecordRunWorkflowPayload { + run_kind: 'process'; + run_type?: ProcessRunType; +} + +export type RecordRunPayload, TSource = RunSource> = + | RecordAgentRunPayload + | RecordProcessRunPayload; + +/** + * @internal Attaches the first Temporal run id after a pre-created run record + * has successfully started its workflow. + */ +export interface BindRunWorkflowPayload extends Required { + status?: AgentRunStatus; + activity_state?: ConversationActivityState; +} + /** * Filters for listing agent runs. */ diff --git a/packages/common/src/store/process-validation.test.ts b/packages/common/src/store/process-validation.test.ts index 75d695379..471423589 100644 --- a/packages/common/src/store/process-validation.test.ts +++ b/packages/common/src/store/process-validation.test.ts @@ -98,6 +98,99 @@ describe("process definition validation", () => { ); }); + it("accepts process nodes as standalone nodes and parallel children", () => { + const child = validDefinition(); + child.process = "child_approval"; + + const definition = validDefinition(); + definition.initial = "fanout"; + definition.context.schema = { + type: "object", + properties: { + items: { type: "array", items: { type: "object" } }, + results: { type: "array" }, + }, + additionalProperties: true, + }; + definition.context.initial = { items: [] }; + definition.nodes = { + fanout: { + type: "parallel", + foreach: "items", + as: "item", + item_id: "{{item.id}}", + max_concurrency: 10, + collect: { + into: "results", + include: ["status", "index", "item_id", "output", "child_run_id"], + }, + node: { + type: "process", + process_definition: child, + input: { invoice: "{{item}}" }, + returns: { from: "context.approved" }, + }, + transitions: [{ to: "approved" }], + }, + approved: { + type: "final", + }, + }; + + expect(() => validateProcessDefinitionBody(definition)).not.toThrow(); + }); + + it("rejects process nodes without a referenced or inline process", () => { + const definition = validDefinition(); + definition.nodes.review = { + type: "process", + transitions: [{ to: "approved" }], + }; + + expect(() => validateProcessDefinitionBody(definition)).toThrow( + 'process node "review" is missing process or process_definition', + ); + }); + + it("rejects invalid parallel fanout controls", () => { + const definition = validDefinition(); + definition.initial = "fanout"; + definition.context.schema = { + type: "object", + properties: { + items: { type: "array" }, + }, + additionalProperties: true, + }; + definition.context.initial = { items: [] }; + definition.nodes = { + fanout: { + type: "parallel", + foreach: "items", + max_concurrency: 0, + collect: { + into: "", + include: ["bogus" as "status"], + }, + node: { + type: "process", + process: "64f000000000000000000000", + }, + transitions: [{ to: "approved" }], + }, + approved: { + type: "final", + }, + }; + + const result = getProcessDefinitionValidationResult(definition); + + expect(result.valid).toBe(false); + expect(result.errors).toContain('parallel node "fanout" max_concurrency must be a positive integer'); + expect(result.errors).toContain('parallel node "fanout" collect.into is required'); + expect(result.errors).toContain('parallel node "fanout" collect.include has invalid field "bogus"'); + }); + it("reports all structural errors without importing runtime schema validators", () => { const definition = { process: "", diff --git a/packages/common/src/store/process-validation.ts b/packages/common/src/store/process-validation.ts index 856ae4b0e..76bc04bd4 100644 --- a/packages/common/src/store/process-validation.ts +++ b/packages/common/src/store/process-validation.ts @@ -69,11 +69,40 @@ function validateNodeDefinition( errors.push(`human_task node "${nodeId}" task fields must be an array`); } } - if (node.type === "parallel" && node.node) { - if (!isParallelChildNodeType(node.node.type)) { - errors.push(`parallel node "${nodeId}" has unsupported child node type "${String(node.node.type)}"`); + if (node.type === "process") { + if (!node.process && !node.process_definition) { + errors.push(`process node "${nodeId}" is missing process or process_definition`); + } + if (node.run_type && !isProcessNodeRunType(node.run_type)) { + errors.push(`process node "${nodeId}" has invalid run_type "${String(node.run_type)}"`); + } + if (node.returns?.from !== undefined && typeof node.returns.from !== "string") { + errors.push(`process node "${nodeId}" returns.from must be a string`); + } + if (node.returns?.context !== undefined && !isStringArray(node.returns.context)) { + errors.push(`process node "${nodeId}" returns.context must be an array of strings`); + } + if (node.process_definition) { + const childResult = getProcessDefinitionValidationResult(node.process_definition); + for (const error of childResult.errors) { + errors.push(`process node "${nodeId}" process_definition: ${error}`); + } + } + } + if (node.type === "parallel") { + if (node.max_concurrency !== undefined && (!Number.isInteger(node.max_concurrency) || node.max_concurrency < 1)) { + errors.push(`parallel node "${nodeId}" max_concurrency must be a positive integer`); + } + if (node.item_id !== undefined && typeof node.item_id !== "string") { + errors.push(`parallel node "${nodeId}" item_id must be a string`); + } + validateParallelCollectDefinition(nodeId, node.collect, errors); + if (node.node) { + if (!isParallelChildNodeType(node.node.type)) { + errors.push(`parallel node "${nodeId}" has unsupported child node type "${String(node.node.type)}"`); + } + validateNodeDefinition(definition, `${nodeId}.node`, node.node, errors); } - validateNodeDefinition(definition, `${nodeId}.node`, node.node, errors); } if (node.failure_policy && !isParallelFailurePolicy(node.failure_policy)) { errors.push(`node "${nodeId}" has invalid failure_policy "${String(node.failure_policy)}"`); @@ -104,12 +133,17 @@ function isProcessNodeType(value: string): boolean { return value === "tool" || value === "interaction" || value === "agent" + || value === "process" || value === "human_task" || value === "parallel" || value === "condition" || value === "final"; } +function isProcessNodeRunType(value: string): boolean { + return value === "supervised" || value === "programmatic"; +} + function isTransitionTrigger(value: string): boolean { return value === "auto" || value === "agent" || value === "user"; } @@ -122,9 +156,60 @@ function isParallelChildNodeType(value: string): boolean { return value === "tool" || value === "interaction" || value === "agent" + || value === "process" || value === "condition"; } +function validateParallelCollectDefinition(nodeId: string, collect: NodeDefinition["collect"], errors: string[]) { + if (collect === undefined) { + return; + } + if (typeof collect === "string") { + if (!collect) { + errors.push(`parallel node "${nodeId}" collect must not be empty`); + } + return; + } + if (!isRecord(collect)) { + errors.push(`parallel node "${nodeId}" collect must be a string or object`); + return; + } + if (typeof collect.into !== "string" || !collect.into) { + errors.push(`parallel node "${nodeId}" collect.into is required`); + } + if (collect.mode !== undefined && collect.mode !== "array") { + errors.push(`parallel node "${nodeId}" collect.mode must be "array"`); + } + if (collect.include !== undefined) { + if (!Array.isArray(collect.include)) { + errors.push(`parallel node "${nodeId}" collect.include must be an array`); + return; + } + for (const field of collect.include) { + if (typeof field !== "string" || !isParallelCollectField(field)) { + errors.push(`parallel node "${nodeId}" collect.include has invalid field "${String(field)}"`); + } + } + } +} + +function isParallelCollectField(value: string): boolean { + return value === "status" + || value === "index" + || value === "item" + || value === "item_id" + || value === "output" + || value === "context_update" + || value === "error" + || value === "child_run_id" + || value === "child_workflow_id" + || value === "child_workflow_run_id"; +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every(item => typeof item === "string"); +} + function validateGuardRule(label: string, rule: unknown, errors: string[]) { const result = inspectGuardRule(rule); if (result.depth > MAX_PROCESS_GUARD_DEPTH) { diff --git a/packages/common/src/store/process.ts b/packages/common/src/store/process.ts index 6a28d2766..e4dbfbbeb 100644 --- a/packages/common/src/store/process.ts +++ b/packages/common/src/store/process.ts @@ -9,6 +9,7 @@ export type ProcessNodeType = | 'tool' | 'interaction' | 'agent' + | 'process' | 'human_task' | 'parallel' | 'condition' @@ -16,6 +17,19 @@ export type ProcessNodeType = export type TransitionTrigger = 'auto' | 'agent' | 'user'; export type ParallelFailurePolicy = 'fail_fast' | 'collect_errors'; +export type ProcessNodeRunType = 'supervised' | 'programmatic'; +export type ParallelCollectMode = 'array'; +export type ParallelCollectField = + | 'status' + | 'index' + | 'item' + | 'item_id' + | 'output' + | 'context_update' + | 'error' + | 'child_run_id' + | 'child_workflow_id' + | 'child_workflow_run_id'; export interface TransitionDefinition { to: string; @@ -43,10 +57,42 @@ export interface HumanTaskDefinition { fields: TaskField[]; } +export interface ProcessNodeReturnsDefinition { + /** + * Path to read from the completed child process state. Use `context.foo` + * for child context values or `state.sequence` for process-state fields. + * If omitted, the child context is used as the node output. + */ + from?: string; + /** + * Select specific fields from the completed child process context. + * Ignored when `from` is set. + */ + context?: string[]; +} + +export interface ParallelCollectDefinition { + /** + * Context key that receives the collected results. + */ + into: string; + mode?: ParallelCollectMode; + /** + * Fields to include in each collected item. Defaults to the operational + * envelope: status, index, item_id, output, error, and child_run_id. + */ + include?: ParallelCollectField[]; +} + export interface NodeDefinition { type: ProcessNodeType; tool?: string; interaction?: string; + process?: string; + process_definition?: ProcessDefinitionBody; + process_version?: number; + run_type?: ProcessNodeRunType; + returns?: ProcessNodeReturnsDefinition; prompt?: string; input?: Record; config?: Record; @@ -75,8 +121,10 @@ export interface NodeDefinition { task?: HumanTaskDefinition; foreach?: string; as?: string; + item_id?: string; node?: NodeDefinition; - collect?: string; + max_concurrency?: number; + collect?: string | ParallelCollectDefinition; failure_policy?: ParallelFailurePolicy; branches?: BranchDefinition[]; } diff --git a/packages/common/src/user.ts b/packages/common/src/user.ts index af745de1f..8fb4de991 100644 --- a/packages/common/src/user.ts +++ b/packages/common/src/user.ts @@ -138,7 +138,7 @@ export interface OnboardingProgress { interactions: boolean, prompts: boolean, environments: boolean, - default_environment_defined: boolean + default_environment_defined: boolean, } From 16f0c28144508e136be5ef4c977166df32996981 Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Mon, 20 Apr 2026 03:02:28 +0900 Subject: [PATCH 08/75] feat: add process schema definition and validation tests; enhance MonacoEditor with path prop --- packages/common/src/store/index.ts | 1 + .../common/src/store/process-schema.test.ts | 92 ++++ packages/common/src/store/process-schema.ts | 403 ++++++++++++++++++ .../src/widgets/monacoEditor/MonacoEditor.tsx | 10 +- 4 files changed, 502 insertions(+), 4 deletions(-) create mode 100644 packages/common/src/store/process-schema.test.ts create mode 100644 packages/common/src/store/process-schema.ts diff --git a/packages/common/src/store/index.ts b/packages/common/src/store/index.ts index 91dbc666a..790db34c8 100644 --- a/packages/common/src/store/index.ts +++ b/packages/common/src/store/index.ts @@ -8,6 +8,7 @@ export * from "./dsl-workflow.js"; export * from "./hive-memory.js"; export * from "./object-types.js"; export * from "./process.js"; +export * from "./process-schema.js"; export * from "./process-validation.js"; export * from "./schedule.js"; export * from "./signals.js"; diff --git a/packages/common/src/store/process-schema.test.ts b/packages/common/src/store/process-schema.test.ts new file mode 100644 index 000000000..d5cb46731 --- /dev/null +++ b/packages/common/src/store/process-schema.test.ts @@ -0,0 +1,92 @@ +import Ajv from "ajv"; +import { describe, expect, it } from "vitest"; +import type { ProcessDefinitionBody } from "./process.js"; +import { ProcessDefinitionBodyJsonSchema } from "./process-schema.js"; + +function validDefinition(): ProcessDefinitionBody { + return { + process: "invoice_review", + initial: "fanout", + context: { + schema: { + type: "object", + additionalProperties: true, + properties: { + invoices: { type: "array" }, + results: { type: "array" }, + }, + }, + initial: { + invoices: [], + }, + }, + nodes: { + fanout: { + type: "parallel", + foreach: "invoices", + as: "invoice", + item_id: "{{invoice.id}}", + max_concurrency: 10, + failure_policy: "collect_errors", + collect: { + into: "results", + include: ["status", "index", "item_id", "output", "child_run_id"], + }, + node: { + type: "process", + process: "invoice_child_review", + run_type: "programmatic", + returns: { + from: "context.review", + }, + }, + transitions: [{ to: "done" }], + }, + done: { + type: "final", + }, + }, + }; +} + +describe("process definition JSON schema", () => { + it("accepts current process engine definition features", () => { + const validate = new Ajv({ allErrors: true, strict: false }).compile(ProcessDefinitionBodyJsonSchema); + + expect(validate(validDefinition())).toBe(true); + }); + + it("rejects malformed process definition shape for editor diagnostics", () => { + const validate = new Ajv({ allErrors: true, strict: false }).compile(ProcessDefinitionBodyJsonSchema); + const invalidDefinition = { + process: "invoice_review", + initial: "fanout", + context: { + schema: { + type: "object", + }, + initial: {}, + }, + nodes: { + fanout: { + type: "parallel", + collect: { + into: "results", + include: ["bogus"], + }, + node: { + type: "human_task", + }, + transitions: [{ to: "done", trigger: "manual" }], + }, + done: { + type: "final", + }, + }, + }; + + expect(validate(invalidDefinition)).toBe(false); + const messages = validate.errors?.map(error => error.message ?? "") ?? []; + expect(messages).toContain("must be equal to one of the allowed values"); + }); +}); diff --git a/packages/common/src/store/process-schema.ts b/packages/common/src/store/process-schema.ts new file mode 100644 index 000000000..94b707a94 --- /dev/null +++ b/packages/common/src/store/process-schema.ts @@ -0,0 +1,403 @@ +import type { JSONSchemaType } from "../json-schema.js"; + +export const PROCESS_DEFINITION_JSON_SCHEMA_ID = "https://schemas.vertesia.com/process-definition.schema.json"; + +export const ProcessDefinitionBodyJsonSchema = { + $id: PROCESS_DEFINITION_JSON_SCHEMA_ID, + type: "object", + description: "A process definition describes a state-machine workflow executed by the Process Engine.", + properties: { + process: { + type: "string", + minLength: 1, + description: "Stable process code, for example invoice_approval.", + }, + description: { + type: "string", + nullable: true, + description: "Optional human-readable process description.", + }, + initial: { + type: "string", + minLength: 1, + description: "Initial node id. Semantic validation verifies this exists in nodes.", + }, + model: { + type: "string", + nullable: true, + description: "Optional default model for agent and interaction nodes.", + }, + context: { + $ref: "#/$defs/processContextDefinition", + }, + nodes: { + type: "object", + description: "Map of node id to node definition.", + minProperties: 1, + required: [], + additionalProperties: { + $ref: "#/$defs/nodeDefinition", + }, + }, + }, + required: ["process", "initial", "context", "nodes"], + additionalProperties: false, + $defs: { + processContextDefinition: { + type: "object", + properties: { + schema: { + description: "JSON Schema for the process context.", + anyOf: [ + { type: "boolean" }, + { + type: "object", + additionalProperties: true, + }, + ], + }, + initial: { + type: "object", + description: "Initial context values.", + required: [], + additionalProperties: true, + }, + }, + required: ["schema", "initial"], + additionalProperties: false, + }, + nodeDefinition: { + type: "object", + description: "A state-machine node executed by the Process Engine.", + properties: { + type: { + type: "string", + enum: ["tool", "interaction", "agent", "process", "human_task", "parallel", "condition", "final"], + description: "Node executor type.", + }, + tool: { + type: "string", + nullable: true, + description: "Builtin or remote tool name for tool nodes.", + }, + interaction: { + type: "string", + nullable: true, + description: "Interaction id or sys: interaction for interaction and agent nodes.", + }, + process: { + type: "string", + nullable: true, + description: "Stored child process id or process code for process nodes.", + }, + process_definition: { + $ref: "#", + description: "Inline child process definition for process nodes.", + }, + process_version: { + type: "number", + nullable: true, + description: "Stored child process version.", + }, + run_type: { + type: "string", + enum: ["supervised", "programmatic"], + nullable: true, + description: "Run type for child process nodes.", + }, + returns: { + $ref: "#/$defs/processNodeReturnsDefinition", + }, + prompt: { + type: "string", + nullable: true, + description: "Prompt or instructions for agent and interaction nodes.", + }, + input: { + type: "object", + nullable: true, + description: "Node input template. Values may contain {{context_path}} placeholders.", + required: [], + additionalProperties: true, + }, + config: { + type: "object", + nullable: true, + description: "Executor-specific configuration.", + required: [], + additionalProperties: true, + }, + title: { + type: "string", + nullable: true, + description: "Short display title.", + }, + description: { + type: "string", + nullable: true, + description: "Developer-facing node description.", + }, + human_description: { + type: "string", + nullable: true, + description: "End-user-facing explanation of what this node does.", + }, + writes: { + type: "array", + nullable: true, + description: "Context paths this node may write.", + items: { type: "string" }, + }, + skippable: { + type: "boolean", + nullable: true, + description: "Whether a supervisor may skip this node.", + }, + max_retries: { + type: "number", + nullable: true, + description: "Maximum node retry attempts.", + }, + transitions: { + type: "array", + nullable: true, + description: "Outgoing transitions. Semantic validation verifies targets exist.", + items: { $ref: "#/$defs/transitionDefinition" }, + }, + tools: { + type: "array", + nullable: true, + description: "Additional tool names available to an agent node.", + items: { type: "string" }, + }, + model: { + type: "string", + nullable: true, + description: "Model override for this node.", + }, + task: { + $ref: "#/$defs/humanTaskDefinition", + }, + foreach: { + type: "string", + nullable: true, + description: "Context path to an array for parallel nodes.", + }, + as: { + type: "string", + nullable: true, + description: "Variable name for the current parallel item. Defaults to item.", + }, + item_id: { + type: "string", + nullable: true, + description: "Template for a stable item id in parallel collection output.", + }, + node: { + $ref: "#/$defs/nodeDefinition", + description: "Child node executed for each parallel item.", + }, + max_concurrency: { + type: "integer", + minimum: 1, + nullable: true, + description: "Maximum parallel child executions.", + }, + collect: { + description: "Where and how to collect parallel results.", + oneOf: [ + { type: "string", minLength: 1 }, + { $ref: "#/$defs/parallelCollectDefinition" }, + ], + }, + failure_policy: { + type: "string", + enum: ["fail_fast", "collect_errors"], + nullable: true, + description: "Parallel failure policy.", + }, + branches: { + type: "array", + nullable: true, + description: "Condition-node branches. Semantic validation verifies targets exist.", + items: { $ref: "#/$defs/branchDefinition" }, + }, + }, + required: ["type"], + additionalProperties: false, + }, + transitionDefinition: { + type: "object", + properties: { + to: { + type: "string", + minLength: 1, + description: "Target node id.", + }, + guard: { + $ref: "#/$defs/jsonLogicRule", + description: "JSON Logic guard evaluated before transition.", + }, + trigger: { + type: "string", + enum: ["auto", "agent", "user"], + nullable: true, + description: "Transition trigger. Omitted means auto.", + }, + label: { + type: "string", + nullable: true, + description: "Display label.", + }, + }, + required: ["to"], + additionalProperties: false, + }, + branchDefinition: { + type: "object", + properties: { + to: { + type: "string", + minLength: 1, + description: "Target node id.", + }, + when: { + $ref: "#/$defs/jsonLogicRule", + description: "JSON Logic condition for this branch.", + }, + default: { + type: "boolean", + nullable: true, + description: "Fallback branch used when no condition matches.", + }, + }, + required: ["to"], + additionalProperties: false, + }, + humanTaskDefinition: { + type: "object", + properties: { + title: { + type: "string", + minLength: 1, + description: "Task title shown in the inbox.", + }, + description: { + type: "string", + nullable: true, + description: "Task instructions shown to the assignee.", + }, + assignee: { + type: "string", + nullable: true, + description: "User id or group: assignee reference.", + }, + fields: { + type: "array", + description: "Fields required to complete the task.", + items: { $ref: "#/$defs/taskField" }, + }, + }, + required: ["title", "fields"], + additionalProperties: false, + }, + taskField: { + type: "object", + properties: { + name: { + type: "string", + minLength: 1, + description: "Context/result field name.", + }, + type: { + type: "string", + enum: ["string", "number", "boolean", "select", "text"], + description: "Input field type.", + }, + required: { + type: "boolean", + nullable: true, + description: "Whether the field must be answered.", + }, + label: { + type: "string", + nullable: true, + description: "Display label.", + }, + options: { + type: "array", + nullable: true, + description: "Allowed options for select fields.", + items: { type: "string" }, + }, + default: { + description: "Default field value.", + }, + }, + required: ["name", "type"], + additionalProperties: false, + }, + processNodeReturnsDefinition: { + type: "object", + properties: { + from: { + type: "string", + nullable: true, + description: "Path to read from child state or context.", + }, + context: { + type: "array", + nullable: true, + description: "Child context paths to return.", + items: { type: "string" }, + }, + }, + required: [], + additionalProperties: false, + }, + parallelCollectDefinition: { + type: "object", + properties: { + into: { + type: "string", + minLength: 1, + description: "Context key receiving collected results.", + }, + mode: { + type: "string", + enum: ["array"], + nullable: true, + description: "Collection mode.", + }, + include: { + type: "array", + nullable: true, + description: "Fields included in each collected item.", + items: { + type: "string", + enum: [ + "status", + "index", + "item", + "item_id", + "output", + "context_update", + "error", + "child_run_id", + "child_workflow_id", + "child_workflow_run_id", + ], + }, + }, + }, + required: ["into"], + additionalProperties: false, + }, + jsonLogicRule: { + type: "object", + description: "JSON Logic expression.", + required: [], + additionalProperties: true, + }, + }, +} satisfies JSONSchemaType; diff --git a/packages/ui/src/widgets/monacoEditor/MonacoEditor.tsx b/packages/ui/src/widgets/monacoEditor/MonacoEditor.tsx index 992067e84..f76904f29 100644 --- a/packages/ui/src/widgets/monacoEditor/MonacoEditor.tsx +++ b/packages/ui/src/widgets/monacoEditor/MonacoEditor.tsx @@ -41,6 +41,7 @@ interface MonacoEditorProps { className?: string; editorRef?: RefObject; language?: string; + path?: string; onChange?: (update: ViewUpdate) => void; debounceTimeout?: number; theme?: string; @@ -57,6 +58,7 @@ export function MonacoEditor({ className, editorRef, language = 'javascript', + path, debounceTimeout = 0, options = {}, beforeMount, @@ -129,7 +131,7 @@ export function MonacoEditor({ setEditorValue(actualValue); if (debouncedOnChange) { - const update = { + const update: ViewUpdate = { docChanged: true, state: { doc: { @@ -137,8 +139,7 @@ export function MonacoEditor({ length: actualValue.length } } - } as unknown as ViewUpdate; - // Using type assertion through unknown to avoid complex type mocking + }; debouncedOnChange(update); } @@ -237,6 +238,7 @@ export function MonacoEditor({ height="100%" theme={resolvedTheme === 'dark' ? 'vs-dark' : 'light'} language={language} + path={path} value={editorValue} onChange={handleEditorChange} onMount={handleEditorDidMount} @@ -246,4 +248,4 @@ export function MonacoEditor({ /> ); -} \ No newline at end of file +} From 461f54c69d2afd7f4e11ec6eba8c842c004fd3fc Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Mon, 20 Apr 2026 03:15:12 +0900 Subject: [PATCH 09/75] feat: remove optional xstate_snapshot from ProcessState interface --- packages/common/src/store/process.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/common/src/store/process.ts b/packages/common/src/store/process.ts index e4dbfbbeb..6ecb95e08 100644 --- a/packages/common/src/store/process.ts +++ b/packages/common/src/store/process.ts @@ -185,7 +185,6 @@ export interface ProcessHistoryCheckpoint { } export interface ProcessState { - xstate_snapshot?: any; context: Record; current_node: string; node_history: NodeHistoryEntry[]; From 9cfb1a55a39f3f4fb0f5d70161f7eb650750260e Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Mon, 20 Apr 2026 03:48:06 +0900 Subject: [PATCH 10/75] feat: add PromptRenderResponse interface and update render method to return structured response --- packages/client/src/PromptsApi.ts | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/client/src/PromptsApi.ts b/packages/client/src/PromptsApi.ts index a39ba3fed..396d086d3 100644 --- a/packages/client/src/PromptsApi.ts +++ b/packages/client/src/PromptsApi.ts @@ -1,4 +1,5 @@ -import { ComputePromptFacetPayload, PromptSearchPayload, PromptSearchQuery, PromptTemplate, PromptTemplateForkPayload, PromptTemplateCreatePayload, PromptTemplateRef, PromptTemplateUpdatePayload } from "@vertesia/common"; +import { ComputePromptFacetPayload, PromptSearchPayload, PromptSearchQuery, PromptTemplate, PromptTemplateForkPayload, PromptTemplateCreatePayload, PromptTemplateRef, PromptTemplateUpdatePayload, TemplateType } from "@vertesia/common"; +import { PromptRole } from "@llumiverse/common"; import { ApiTopic, ClientBase } from "@vertesia/api-fetch-client"; export interface ComputePromptFacetsResponse { @@ -6,6 +7,14 @@ export interface ComputePromptFacetsResponse { total?: { count: number }[]; } +export interface PromptRenderResponse { + id: string; + name: string; + role: PromptRole; + content_type: TemplateType; + rendered: string; +} + export default class PromptsApi extends ApiTopic { constructor(parent: ClientBase) { super(parent, "/api/v1/prompts") @@ -97,18 +106,16 @@ export default class PromptsApi extends ApiTopic { }); } - //TODO - Does this exist? /** - * Render a prompt template + * Render a prompt template with the given variables. * @param id of the prompt template to render - * @param payload that will be passed to the prompt template to generate the prompts - * @returns PromptTemplate + * @param payload variables to apply to the template + * @returns { id, name, role, content_type, rendered } * @throws ApiError * @throws 404 if not found - * @throws 400 if payload is invalid - * @throws 500 if render fails - **/ - render(id: string, payload: object): Promise { + * @throws 403 if the prompt is not in the current project + */ + render(id: string, payload: object): Promise { return this.post(`/${id}/render`, { payload }); From c92759f375a33ecd80cdd8434f631ef38f8190db Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Mon, 20 Apr 2026 20:04:12 +0900 Subject: [PATCH 11/75] feat: update endpoint for fetching system tools package to '/studio-tools/package' --- packages/client/src/AppsApi.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/src/AppsApi.ts b/packages/client/src/AppsApi.ts index 887251c44..7680cd971 100644 --- a/packages/client/src/AppsApi.ts +++ b/packages/client/src/AppsApi.ts @@ -34,7 +34,7 @@ export default class AppsApi extends ApiTopic { * render them distinctly. URLs are already resolved per deployment. */ getSystemToolsPackage(scope: string = 'tools'): Promise { - return this.get('/system-tools/package', { query: { scope } }); + return this.get('/studio-tools/package', { query: { scope } }); } /** From 17394c1a6d0564ea8a626d244cb80a2d157ac5d7 Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Wed, 22 Apr 2026 02:15:16 +0900 Subject: [PATCH 12/75] feat(cli): enhance client creation with explicit environment variable support - Updated `createClient` function to prioritize explicit environment variables over profile settings for API key, server URL, store URL, and project ID. - Improved handling of `VERTESIA_TOKEN` to allow for local server usage. - Added checks to fallback to profile values if environment variables are not set. feat(cli): add agent commands to CLI - Integrated `registerAgentsCommand` into the main CLI entry point. - Enhanced profiles command to include options for creating profiles with API keys and project/account IDs. feat(cli): expand object commands with search and query capabilities - Introduced `searchObjects` and `queryObjects` functions to allow for full-text search and querying indexed documents. - Updated `listObjects` and `getObjectText` functions to support JSON output. fix(cli): improve profile management and token handling - Refactored profile commands to ensure proper handling of asynchronous operations and error management. - Enhanced token refresh logic to handle interruptions gracefully. feat(cli): implement server-side improvements for configuration sessions - Updated `startConfigSession` to handle abort signals and ensure proper cleanup. - Improved error handling and validation for callback payloads in server responses. feat(client): add method to retrieve agent runs by ID - Introduced `retrieveRun` method in `AgentsApi` to fetch agent runs while preserving type information. --- packages/cli/src/agents/index.ts | 705 +++++++++++++++++++++ packages/cli/src/client.ts | 47 +- packages/cli/src/index.ts | 18 +- packages/cli/src/objects/commands.ts | 208 +++++- packages/cli/src/objects/index.ts | 36 +- packages/cli/src/profiles/commands.ts | 177 +++++- packages/cli/src/profiles/server/index.ts | 170 +++-- packages/cli/src/profiles/server/server.ts | 9 +- packages/client/src/store/AgentsApi.ts | 10 + 9 files changed, 1243 insertions(+), 137 deletions(-) create mode 100644 packages/cli/src/agents/index.ts diff --git a/packages/cli/src/agents/index.ts b/packages/cli/src/agents/index.ts new file mode 100644 index 000000000..0c95b7d47 --- /dev/null +++ b/packages/cli/src/agents/index.ts @@ -0,0 +1,705 @@ +import { + AgentMessage, + AgentMessageType, + AgentRunResponse, + CreateAgentRunPayload, + CreateProcessRunPayload, + InteractionExecutionConfiguration, + ProcessDefinitionBody, + ProcessRun, + ProcessRunConfig, + ProcessRunType, + Task, + UserInputSignal, +} from "@vertesia/common"; +import chalk from "chalk"; +import { Command } from "commander"; +import { setTimeout as delay } from "node:timers/promises"; +import * as readline from "readline"; +import { getClient } from "../client.js"; +import { readFile, readStdin, writeFile } from "../utils/stdio.js"; + +type StartOptions = { + data?: unknown; + input?: unknown; + output?: unknown; + processId?: unknown; + processDefinition?: unknown; + runType?: unknown; + model?: unknown; + env?: unknown; + userMessage?: unknown; + tags?: unknown; + categories?: unknown; + visibility?: unknown; + tools?: unknown; + collection?: unknown; + inspect?: boolean; + json?: unknown; + stream?: boolean; + interactive?: boolean; +}; + +type StreamOptions = { + since?: unknown; + interactive?: boolean; +}; + +type ReplyOptions = { + input?: unknown; + json?: boolean; + output?: unknown; +}; + +type AnswerTaskOptions = { + data?: unknown; + input?: unknown; + json?: boolean; + output?: unknown; +}; + +type CancelOptions = { + reason?: unknown; + json?: boolean; + output?: unknown; +}; + +type ListTasksOptions = { + runId?: unknown; + status?: unknown; + assignee?: unknown; + sourceType?: unknown; + limit?: unknown; + json?: boolean; + output?: unknown; +}; + +type InspectOptions = { + details?: boolean; + history?: boolean; + messages?: boolean; + tasks?: boolean; + json?: boolean; + output?: unknown; + limit?: unknown; +}; + +export function registerAgentsCommand(program: Command) { + const agents = program.command("agents") + .description("Start, stream, and inspect durable agent runs"); + + agents.command("start [interaction]") + .description("Start a conversation agent or process run through the Agent Runs API") + .option("-d, --data ", "Inline input data as a JSON object") + .option("-i, --input [file]", "Input data file. Reads stdin when no file is provided") + .option("-o, --output ", "Write the created run JSON to a file") + .option("--process-id ", "Start a stored process definition instead of a conversation agent") + .option("--process-definition ", "Start an inline process definition from a JSON file") + .option("--run-type ", "Process run type: programmatic or supervised", "programmatic") + .option("-e, --env ", "Environment ID for conversation agents") + .option("-m, --model ", "Model override") + .option("--user-message ", "Process run intent passed to supervised mode") + .option("-T, --tags ", "Comma-separated tags") + .option("-C, --categories ", "Comma-separated categories") + .option("--visibility ", "Run visibility: project or private") + .option("--tools ", "Comma-separated tool names for conversation agents") + .option("--collection ", "Collection ID for conversation agents") + .option("--no-stream", "Do not stream the feed after starting") + .option("--no-inspect", "Do not print a short run summary before streaming") + .option("--json", "Print machine-readable JSON for non-streaming output") + .option("--interactive", "Send UserInput signals while streaming") + .action(async (interaction: string | undefined, options: StartOptions) => { + await startAgentRun(program, interaction, options); + }); + + agents.command("stream ") + .description("Stream the message feed for an existing agent or process run") + .option("--since ", "Only stream messages after this timestamp") + .option("-i, --interactive", "Send UserInput signals while streaming") + .action(async (runId: string, options: StreamOptions) => { + await streamAgentRun(program, runId, options); + }); + + agents.command("message [message]") + .alias("reply") + .description("Send a UserInput signal to a running conversation agent or supervised process run") + .option("-i, --input [file]", "Reply text file. Reads stdin when no file is provided") + .option("--json", "Print machine-readable JSON") + .option("-o, --output ", "Write JSON output to a file") + .action(async (runId: string, message: string | undefined, options: ReplyOptions) => { + await replyToAgentRun(program, runId, message, options); + }); + + const tasks = agents.command("tasks") + .description("List durable tasks for agent and process runs"); + + tasks.command("list [runId]") + .description("List tasks, optionally scoped to a run id") + .option("--run-id ", "Filter by run id") + .option("--status ", "Filter by status") + .option("--assignee ", "Filter by assignee principal ref") + .option("--source-type ", "Filter by source type: agent or process") + .option("-l, --limit ", "Maximum tasks to return", "50") + .option("--json", "Print raw JSON") + .option("-o, --output ", "Write JSON output to a file") + .action(async (runId: string | undefined, options: ListTasksOptions) => { + await listAgentTasks(program, runId, options); + }); + + agents.command("answer-task [response]") + .description("Complete a task with a JSON result object or a shorthand response string") + .option("-d, --data ", "Inline task result as a JSON object") + .option("-i, --input [file]", "Task result JSON file. Reads stdin when no file is provided") + .option("--json", "Print machine-readable JSON") + .option("-o, --output ", "Write JSON output to a file") + .action(async (taskId: string, response: string | undefined, options: AnswerTaskOptions) => { + await answerTask(program, taskId, response, options); + }); + + agents.command("cancel ") + .alias("terminate") + .description("Cancel a running agent or process run") + .option("-r, --reason ", "Optional cancellation reason") + .option("--json", "Print machine-readable JSON") + .option("-o, --output ", "Write JSON output to a file") + .action(async (runId: string, options: CancelOptions) => { + await cancelAgentRun(program, runId, options); + }); + + agents.command("inspect ") + .description("Inspect a durable agent or process run") + .option("--details", "Include workflow details") + .option("--history", "Include process node history") + .option("--messages", "Include stored stream messages") + .option("--tasks", "Include tasks for the run") + .option("-l, --limit ", "Message/task limit", "50") + .option("--json", "Print raw JSON") + .option("-o, --output ", "Write JSON output to a file") + .action(async (runId: string, options: InspectOptions) => { + await inspectAgentRun(program, runId, options); + }); +} + +async function startAgentRun(program: Command, interaction: string | undefined, options: StartOptions) { + const client = await getClient(program); + const data = await readOptionalRecordInput(options); + const processId = readOptionalString(options.processId); + const processDefinitionPath = readOptionalString(options.processDefinition); + const stream = options.stream !== false; + + let run: AgentRunResponse, Record>; + if (processId || processDefinitionPath) { + const payload = buildProcessStartPayload(options, data, processId, processDefinitionPath); + run = await client.agents.start(payload); + } else { + if (!interaction) { + throw new Error("Missing interaction. Provide an interaction name, --process-id, or --process-definition."); + } + const payload = buildAgentStartPayload(interaction, options, data); + run = await client.agents.start(payload); + } + + const runJson = JSON.stringify(run, null, 2); + const outputFile = readOptionalString(options.output); + if (outputFile) { + writeFile(outputFile, runJson); + } + + if (options.json || !stream) { + console.log(runJson); + } else if (options.inspect !== false) { + printRunSummary(run); + } + + if (stream) { + await streamAgentRun(program, run.id, { interactive: options.interactive }); + } +} + +function buildAgentStartPayload( + interaction: string, + options: StartOptions, + data: Record | undefined, +): CreateAgentRunPayload, Record> { + return { + interaction, + data, + config: buildInteractionConfig(options), + interactive: options.interactive !== false, + tool_names: readStringList(options.tools), + collection_id: readOptionalString(options.collection), + visibility: readVisibility(options.visibility), + tags: readStringList(options.tags), + categories: readStringList(options.categories), + }; +} + +function buildProcessStartPayload( + options: StartOptions, + data: Record | undefined, + processId: string | undefined, + processDefinitionPath: string | undefined, +): CreateProcessRunPayload> { + const runType = readProcessRunType(options.runType); + return { + process_id: processId, + process_definition: processDefinitionPath ? readProcessDefinition(processDefinitionPath) : undefined, + run_type: runType, + data, + config: buildProcessConfig(options), + visibility: readVisibility(options.visibility), + tags: readStringList(options.tags), + categories: readStringList(options.categories), + }; +} + +async function streamAgentRun(program: Command, runId: string, options: StreamOptions) { + const client = await getClient(program); + const since = readOptionalInteger(options.since); + const abortController = new AbortController(); + let isStopping = false; + let rl: readline.Interface | undefined; + + const stop = () => { + if (isStopping) { + return; + } + isStopping = true; + abortController.abort(); + rl?.close(); + process.off("SIGINT", stop); + process.off("SIGTERM", stop); + }; + + process.on("SIGINT", stop); + process.on("SIGTERM", stop); + + console.log(chalk.bold(`Streaming AgentRun ${runId}`)); + if (options.interactive) { + rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + prompt: "> ", + }); + rl.on("line", (line: string) => { + const message = line.trim(); + if (!message) { + rl?.prompt(); + return; + } + const payload: UserInputSignal = { message }; + client.agents.sendSignal(runId, "UserInput", payload) + .then(() => { + if (!isStopping) { + rl?.prompt(); + } + }) + .catch(error => { + console.error(chalk.red(formatError(error))); + if (!isStopping) { + rl?.prompt(); + } + }); + }); + rl.prompt(); + } + + try { + await client.agents.streamMessages( + runId, + message => printAgentMessage(message), + since, + abortController.signal, + ); + } finally { + stop(); + } +} + +async function replyToAgentRun( + program: Command, + runId: string, + message: string | undefined, + options: ReplyOptions, +) { + const client = await getClient(program); + const payload: UserInputSignal = { + message: await readReplyMessage(message, options), + }; + const response = await client.agents.sendSignal(runId, "UserInput", payload); + printCommandResult(response, options); +} + +async function answerTask( + program: Command, + taskId: string, + response: string | undefined, + options: AnswerTaskOptions, +) { + const client = await getClient(program); + const task = await client.tasks.retrieve(taskId); + const result = await readTaskResult(response, options); + const completed = task.source.type === "process" + ? await completeProcessTask(client, task.source.run_id, taskId, result) + : await client.tasks.complete(taskId, { result }); + printCommandResult(completed, options); +} + +async function cancelAgentRun(program: Command, runId: string, options: CancelOptions) { + const client = await getClient(program); + const response = await client.agents.terminate(runId, readOptionalString(options.reason)); + printCommandResult(response, options); +} + +async function listAgentTasks( + program: Command, + runId: string | undefined, + options: ListTasksOptions, +) { + const client = await getClient(program); + const tasks = await client.tasks.list({ + run_id: readOptionalString(options.runId) ?? runId, + status: readOptionalTaskStatus(options.status), + assignee: readOptionalString(options.assignee), + source_type: readOptionalTaskSourceType(options.sourceType), + limit: readOptionalInteger(options.limit) ?? 50, + }); + + const outputFile = readOptionalString(options.output); + if (options.json || outputFile) { + const json = JSON.stringify(tasks, null, 2); + if (outputFile) { + writeFile(outputFile, json); + } else { + console.log(json); + } + return; + } + + printTasks(tasks); +} + +async function inspectAgentRun(program: Command, runId: string, options: InspectOptions) { + const client = await getClient(program); + const run = await client.agents.retrieveRun(runId); + const includeMessages = options.messages === true; + const includeTasks = options.tasks === true || isProcessRun(run); + const includeHistory = options.history === true || isProcessRun(run); + const limit = readOptionalInteger(options.limit) ?? 50; + + const result: Record = { run }; + if (options.details) { + result.details = await client.agents.getRunDetails(runId, { includeHistory: true }); + } + if (includeHistory && isProcessRun(run)) { + result.history = await client.agents.getHistory(runId); + result.context = await client.agents.getContext(runId); + } + if (includeMessages) { + result.messages = (await client.agents.retrieveMessages(runId)).slice(-limit); + } + if (includeTasks) { + result.tasks = await client.tasks.list({ run_id: runId, limit }); + } + + const outputFile = readOptionalString(options.output); + if (options.json || outputFile) { + const json = JSON.stringify(result, null, 2); + if (outputFile) { + writeFile(outputFile, json); + } else { + console.log(json); + } + return; + } + + printRunSummary(run); + if (Array.isArray(result.tasks)) { + printTasks(result.tasks); + } + if (isProcessRun(run)) { + const state = run.process_state; + console.log(chalk.bold("Process")); + console.log(` current_node: ${state.current_node}`); + console.log(` sequence: ${state.sequence ?? 0}`); + console.log(` history_entries: ${state.node_history?.length ?? 0}`); + } +} + +async function readOptionalRecordInput(options: StartOptions): Promise | undefined> { + const inline = readOptionalString(options.data); + if (inline) { + return parseJsonRecord(inline, "--data"); + } + + if (options.input === true) { + return parseJsonRecord(await readStdin(), "stdin"); + } + + const inputFile = readOptionalString(options.input); + if (inputFile) { + return parseJsonRecord(readFile(inputFile), inputFile); + } + + return undefined; +} + +async function readReplyMessage(message: string | undefined, options: ReplyOptions): Promise { + if (typeof message === "string" && message.length > 0) { + return message; + } + + if (options.input === true) { + return (await readStdin()).trim(); + } + + const inputFile = readOptionalString(options.input); + if (inputFile) { + return readFile(inputFile).trim(); + } + + throw new Error("Missing reply message. Provide a positional message or --input."); +} + +async function readTaskResult( + response: string | undefined, + options: AnswerTaskOptions, +): Promise> { + const inline = readOptionalString(options.data); + if (inline) { + return parseJsonRecord(inline, "--data"); + } + + if (options.input === true) { + return parseJsonRecord(await readStdin(), "stdin"); + } + + const inputFile = readOptionalString(options.input); + if (inputFile) { + return parseJsonRecord(readFile(inputFile), inputFile); + } + + if (typeof response === "string" && response.length > 0) { + return { response }; + } + + throw new Error("Missing task result. Provide a positional response, --data, or --input."); +} + +async function completeProcessTask( + client: Awaited>, + runId: string, + taskId: string, + result: Record, +): Promise { + await client.agents.answerTask(runId, taskId, result); + return waitForTaskUpdate(client, taskId); +} + +async function waitForTaskUpdate( + client: Awaited>, + taskId: string, +): Promise { + let latest = await client.tasks.retrieve(taskId); + if (latest.status !== "pending" && latest.status !== "in_progress") { + return latest; + } + + for (let attempt = 0; attempt < 20; attempt += 1) { + await delay(250); + latest = await client.tasks.retrieve(taskId); + if (latest.status !== "pending" && latest.status !== "in_progress") { + return latest; + } + } + + return latest; +} + +function buildInteractionConfig(options: StartOptions): InteractionExecutionConfiguration | undefined { + const config: InteractionExecutionConfiguration = {}; + const environment = readOptionalString(options.env); + const model = readOptionalString(options.model); + if (environment) { + config.environment = environment; + } + if (model) { + config.model = model; + } + return Object.keys(config).length ? config : undefined; +} + +function buildProcessConfig(options: StartOptions): ProcessRunConfig | undefined { + const config: ProcessRunConfig = {}; + const model = readOptionalString(options.model); + const userMessage = readOptionalString(options.userMessage); + if (model) { + config.model = model; + } + if (userMessage) { + config.user_message = userMessage; + } + return Object.keys(config).length ? config : undefined; +} + +function readProcessDefinition(file: string): ProcessDefinitionBody { + const parsed = JSON.parse(readFile(file)); + if (!isProcessDefinitionBody(parsed)) { + throw new Error(`Invalid process definition in ${file}`); + } + return parsed; +} + +function parseJsonRecord(input: string, label: string): Record { + const parsed = JSON.parse(input); + if (!isRecord(parsed)) { + throw new Error(`${label} must be a JSON object`); + } + return parsed; +} + +function readOptionalString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function readStringList(value: unknown): string[] | undefined { + const raw = readOptionalString(value); + if (!raw) { + return undefined; + } + const items = raw.split(",").map(item => item.trim()).filter(Boolean); + return items.length ? items : undefined; +} + +function readOptionalInteger(value: unknown): number | undefined { + const raw = readOptionalString(value); + if (!raw) { + return undefined; + } + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function readVisibility(value: unknown): "project" | "private" | undefined { + const raw = readOptionalString(value); + if (!raw) { + return undefined; + } + if (raw !== "project" && raw !== "private") { + throw new Error("visibility must be project or private"); + } + return raw; +} + +function readProcessRunType(value: unknown): ProcessRunType { + const raw = readOptionalString(value) ?? "programmatic"; + if (raw !== "programmatic" && raw !== "supervised") { + throw new Error("run-type must be programmatic or supervised"); + } + return raw; +} + +function readOptionalTaskStatus(value: unknown): Task["status"] | undefined { + const raw = readOptionalString(value); + if (!raw) { + return undefined; + } + if (raw !== "pending" && raw !== "in_progress" && raw !== "completed" && raw !== "cancelled") { + throw new Error("status must be pending, in_progress, completed, or cancelled"); + } + return raw; +} + +function readOptionalTaskSourceType(value: unknown): Task["source"]["type"] | undefined { + const raw = readOptionalString(value); + if (!raw) { + return undefined; + } + if (raw !== "agent" && raw !== "process") { + throw new Error("source-type must be agent or process"); + } + return raw; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function isProcessDefinitionBody(value: unknown): value is ProcessDefinitionBody { + return isRecord(value) + && typeof value.process === "string" + && typeof value.initial === "string" + && isRecord(value.context) + && isRecord(value.nodes); +} + +function isProcessRun(run: AgentRunResponse, Record>): run is ProcessRun { + return run.run_kind === "process"; +} + +function printRunSummary(run: AgentRunResponse, Record>) { + console.log(chalk.bold("AgentRun")); + console.log(` id: ${run.id}`); + console.log(` kind: ${run.run_kind}`); + console.log(` run_type: ${run.run_type}`); + console.log(` status: ${run.status}`); + console.log(` title: ${run.title ?? "n/a"}`); + if (run.run_kind === "agent") { + console.log(` interaction: ${run.interaction_name ?? run.interaction}`); + } + if (isProcessRun(run)) { + console.log(` process: ${run.process_definition_snapshot.process}`); + console.log(` current_node: ${run.process_state.current_node}`); + } +} + +function printAgentMessage(message: AgentMessage) { + const timestamp = message.timestamp ? new Date(message.timestamp).toISOString() : new Date().toISOString(); + const type = message.type != null ? AgentMessageType[message.type] ?? String(message.type) : "MESSAGE"; + const body = typeof message.message === "string" + ? message.message + : JSON.stringify(message.message, null, 2); + console.log(`${chalk.gray(timestamp)} ${chalk.cyan(type)} ${body ?? ""}`); + if (message.details) { + console.log(chalk.gray(JSON.stringify(message.details, null, 2))); + } +} + +function printTasks(tasks: Task[]) { + if (!tasks.length) { + console.log("No tasks found"); + return; + } + console.log(chalk.bold("Tasks")); + for (const task of tasks) { + const source = `${task.source.type}:${task.source.run_id}`; + const assignee = task.assignee ? ` assignee=${task.assignee}` : ""; + console.log(` ${task.id} ${task.status} ${task.title} (${source})${assignee}`); + } +} + +function printCommandResult(result: unknown, options: { json?: boolean; output?: unknown }) { + const json = JSON.stringify(result, null, 2); + const outputFile = readOptionalString(options.output); + if (outputFile) { + writeFile(outputFile, json); + } + + if (options.json || outputFile) { + if (!outputFile) { + console.log(json); + } + return; + } + + if (isRecord(result) && typeof result.message === "string") { + console.log(result.message); + return; + } + + console.log(json); +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/cli/src/client.ts b/packages/cli/src/client.ts index 00bd064a0..34a63310f 100644 --- a/packages/cli/src/client.ts +++ b/packages/cli/src/client.ts @@ -21,30 +21,45 @@ export async function getClient(_program?: Command): Promise { } async function createClient(profile: Profile | undefined): Promise { - // Priority 1: VERTESIA_TOKEN (contains embedded endpoint URLs, used by agent sandboxes) - const token = process.env.VERTESIA_TOKEN; - if (token) { - return VertesiaClient.fromAuthToken(token); - } - - // Priority 2: Profile config or individual env vars - // Support both new VERTESIA_* and legacy COMPOSABLE_PROMPTS_* env vars + // Explicit environment overrides should win over profile settings so the same + // credential can be reused against a local deployment without changing profiles. const env = { - apikey: profile?.apikey - || process.env.VERTESIA_APIKEY + apikey: process.env.VERTESIA_APIKEY || process.env.COMPOSABLE_PROMPTS_APIKEY, - serverUrl: profile?.studio_server_url - || process.env.VERTESIA_SERVER_URL + serverUrl: process.env.VERTESIA_SERVER_URL || process.env.COMPOSABLE_PROMPTS_SERVER_URL!, - storeUrl: profile?.zeno_server_url - || process.env.VERTESIA_STORE_URL + storeUrl: process.env.VERTESIA_STORE_URL || process.env.ZENO_SERVER_URL!, - projectId: profile?.project - || process.env.VERTESIA_PROJECT_ID + projectId: process.env.VERTESIA_PROJECT_ID || process.env.COMPOSABLE_PROMPTS_PROJECT_ID + || profile?.project || undefined, sessionTags: profile?.session_tags ? profile.session_tags.split(/\s*,\s*/) : 'cli', }; + if (!env.apikey && profile?.apikey) { + env.apikey = profile.apikey; + } + if (!env.serverUrl && profile?.studio_server_url) { + env.serverUrl = profile.studio_server_url; + } + if (!env.storeUrl && profile?.zeno_server_url) { + env.storeUrl = profile.zeno_server_url; + } + + // VERTESIA_TOKEN contains endpoint URLs, but explicit endpoint env vars + // should win so the same token can be used against a local server. + const token = process.env.VERTESIA_TOKEN; + if (token) { + const endpoints = env.serverUrl && env.storeUrl + ? { + studio: env.serverUrl, + store: env.storeUrl, + token: process.env.VERTESIA_TOKEN_SERVER_URL, + } + : undefined; + return VertesiaClient.fromAuthToken(token, undefined, endpoints); + } + return new VertesiaClient(env); } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index d966bf10a..b489f87f4 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,6 +1,7 @@ import { setupMemoCommand } from '@vertesia/memory-cli'; import { Command } from 'commander'; import { registerAppsCommand } from './apps/index.js'; +import { registerAgentsCommand } from './agents/index.js'; import { registerArtifactsCommand } from './artifacts/index.js'; import runExport from './codegen/index.js'; import { genTestData } from './datagen/index.js'; @@ -9,7 +10,7 @@ import { listInteractions } from './interactions/index.js'; import { getPublishMemoryAction } from './memory/index.js'; import { registerObjectsCommand } from './objects/index.js'; import { getVersion, upgrade } from './package.js'; -import { createProfile, deleteProfile, listProfiles, showActiveAuthToken, showProfile, tryRefreshToken, updateCurrentProfile, updateProfile, useProfile } from './profiles/commands.js'; +import { createProfile, deleteProfile, listProfiles, showActiveAuthToken, showProfile, tryRefreshToken, updateCurrentProfile, updateProfile, useProfile, type CreateProfileOptions } from './profiles/commands.js'; import { AVAILABLE_REGIONS, DEFAULT_REGION, getConfigFile } from './profiles/index.js'; import { listProjects } from './projects/index.js'; import runInteraction from './run/index.js'; @@ -38,15 +39,11 @@ const authRoot = program.command("auth") authRoot.command("token") .description("Show the auth token used by the current selected profile.") - .action(() => { - showActiveAuthToken(); - }) + .action(() => showActiveAuthToken()) authRoot.command("refresh") .description("Refresh the auth token used by the current profile. An alias to 'vertesia profiles refresh'.") - .action(() => { - updateCurrentProfile(); - }) + .action(() => updateCurrentProfile()) program.command("envs [envId]") .description("List the environments you have access to") @@ -129,6 +126,7 @@ setupMemoCommand(memoCmd, getPublishMemoryAction(program)); registerWorkerCommand(program); registerAppsCommand(program); +registerAgentsCommand(program); registerArtifactsCommand(program); const profilesRoot = program.command("profiles") @@ -151,12 +149,12 @@ profilesRoot.command('add [name]') .alias('create') .option("-t, --target ", "The target environment for the profile. Possible values are: local, dev-main, dev-preview, preview, prod or a custom URL.") .option("-r, --region ", `Deployment region: ${AVAILABLE_REGIONS.join(', ')}. Defaults to ${DEFAULT_REGION}. Only applies to preview and prod targets.`) - .option("-k, --apikey ", "The API key to use for the profile") + .option("-k, --apikey ", "The API key or auth token to use for the profile") .option("-p, --project ", "The project ID to use for the profile") .option("-a, --account ", "The account ID to use for the profile") .description("Create a new configuration profile") - .action((name?: string, options?: Record) => { - createProfile(name, options || {}); + .action(async (name: string | undefined, options: CreateProfileOptions) => { + await createProfile(name, options); }); profilesRoot.command('edit [name]') .alias('update') diff --git a/packages/cli/src/objects/commands.ts b/packages/cli/src/objects/commands.ts index bfa1571ce..cb3fdc11d 100644 --- a/packages/cli/src/objects/commands.ts +++ b/packages/cli/src/objects/commands.ts @@ -1,6 +1,13 @@ -import { VertesiaClient } from "@vertesia/client"; +import { QueryResult, VertesiaClient } from "@vertesia/client"; import { NodeStreamSource } from "@vertesia/client/node"; -import { ContentObject, ContentObjectTypeItem, CreateContentObjectPayload } from "@vertesia/common"; +import { + ComplexSearchPayload, + ContentObject, + ContentObjectItem, + ContentObjectTypeItem, + CreateContentObjectPayload, + ObjectSearchPayload, +} from "@vertesia/common"; import { Command } from "commander"; import enquirer from "enquirer"; import { Stats, createReadStream, createWriteStream, type Dirent } from "node:fs"; @@ -17,6 +24,26 @@ const AUTOMATIC_TYPE_SELECTION = "auto"; const AUTOMATIC_TYPE_SELECTION_DESC = "Auto (Vertesia will analyze the file and select the most appropriate type)"; const TYPE_SELECTION_ERROR = "TypeSelectionError"; +interface JsonOutputOptions { + json?: boolean; +} + +interface ListObjectsOptions extends JsonOutputOptions { + limit?: string; + skip?: string; +} + +interface SearchObjectsOptions extends JsonOutputOptions { + limit?: string; + type?: string; + path?: string; + select?: string; +} + +interface QueryObjectsOptions extends JsonOutputOptions { + dsl?: string; +} + function splitInChunksWithSize(arr: Array, size: number): T[][] { if (size < 1) { return []; @@ -242,10 +269,64 @@ export async function getObject(program: Command, objectId: string, _options: Re console.log(object); } -export async function listObjects(program: Command, _folderPath: string | undefined, _options: Record) { +export async function getObjectText(program: Command, objectId: string, options: JsonOutputOptions) { + const client = await getClient(program); + const text = await client.objects.getObjectText(objectId); + if (options.json) { + printJson(text); + return; + } + console.log(text.text); +} + +export async function listObjects(program: Command, folderPath: string | undefined, options: ListObjectsOptions) { + const client = await getClient(program); + const payload: ObjectSearchPayload = { + limit: readOptionalIntegerOption(options.limit), + offset: readOptionalIntegerOption(options.skip), + }; + if (folderPath) { + payload.query = { location: folderPath }; + } + const objects = await client.objects.list(payload); + if (options.json) { + printJson(objects); + return; + } + printObjectItems(objects); +} + +export async function searchObjects(program: Command, query: string, options: SearchObjectsOptions) { + const client = await getClient(program); + const payload: ComplexSearchPayload = { + limit: readOptionalIntegerOption(options.limit) ?? 20, + select: options.select, + query: { + full_text: query, + ...(options.type ? { type: options.type } : {}), + ...(options.path ? { location: options.path } : {}), + }, + }; + const results = await client.objects.search(payload); + if (options.json) { + printJson(results); + return; + } + printObjectItems(results.results); + if (results.facets.total !== undefined) { + console.error(`Found ${results.facets.total} results`); + } +} + +export async function queryObjects(program: Command, options: QueryObjectsOptions) { + const payload = readQueryPayload(options); const client = await getClient(program); - const objects = await client.objects.list(); - console.log(objects.map(o => `${o.id}\t ${o.name}`).join('\n')); + const result = await client.store.query.execute(payload); + if (options.json) { + printJson(result); + return; + } + printQueryResult(result); } export async function listTypes(program: Command) { @@ -292,4 +373,119 @@ export async function downloadObjectContent(program: Command, objectId: string, await pipeline(nodeStream, writeStream); console.log(`Downloaded to: ${outputPath}`); -} \ No newline at end of file +} + +function readOptionalIntegerOption(value: string | undefined): number | undefined { + if (value === undefined || value === "") { + return undefined; + } + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + console.error(`Invalid numeric option: ${value}`); + process.exit(2); + } + return parsed; +} + +function readQueryPayload(options: QueryObjectsOptions): { dsl: Record } { + if (!options.dsl) { + console.error("Specify --dsl with a JSON object."); + process.exit(2); + } + try { + const dsl = JSON.parse(options.dsl ?? ""); + if (!isRecord(dsl)) { + console.error("Invalid JSON for --dsl: expected a JSON object."); + process.exit(2); + } + return { dsl }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Invalid JSON for --dsl: ${message}`); + process.exit(2); + } +} + +function printJson(value: unknown) { + console.log(JSON.stringify(value, null, 2)); +} + +function printObjectItems(objects: ContentObjectItem[]) { + if (objects.length === 0) { + console.log("No objects found"); + return; + } + console.log( + objects + .map((object) => { + const typeName = readObjectTypeName(object); + const location = object.location ?? ""; + const status = object.status ?? ""; + return [object.id, object.name, typeName, status, location].join("\t"); + }) + .join("\n"), + ); +} + +function readObjectTypeName(object: ContentObjectItem): string { + if (!object.type) { + return ""; + } + if (typeof object.type === "string") { + return object.type; + } + return object.type.name || object.type.id || object.type.code || ""; +} + +function printQueryResult(result: QueryResult) { + if (result.type === "dsl") { + if (!result.hits || result.hits.length === 0) { + console.log("No hits"); + return; + } + console.log( + result.hits + .map((hit) => JSON.stringify({ + id: hit.id, + score: hit.score, + source: hit.source, + })) + .join("\n"), + ); + return; + } + + const columns = result.columns?.map((column) => column.name) ?? []; + const rows = result.rows ?? []; + if (columns.length > 0) { + console.log(columns.join("\t")); + } + if (rows.length === 0) { + if (columns.length === 0) { + console.log("No rows"); + } + return; + } + console.log( + rows + .map((row) => row.map((value) => formatQueryValue(value)).join("\t")) + .join("\n"), + ); +} + +function formatQueryValue(value: unknown): string { + if (value === null || value === undefined) { + return ""; + } + if (typeof value === "string") { + return value; + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + return JSON.stringify(value); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/cli/src/objects/index.ts b/packages/cli/src/objects/index.ts index 537407304..d5dabbbae 100644 --- a/packages/cli/src/objects/index.ts +++ b/packages/cli/src/objects/index.ts @@ -1,5 +1,15 @@ import { Command } from "commander"; -import { createObject, deleteObject, downloadObjectContent, getObject, listObjects, updateObject } from "./commands.js"; +import { + createObject, + deleteObject, + downloadObjectContent, + getObject, + getObjectText, + listObjects, + queryObjects, + searchObjects, + updateObject, +} from "./commands.js"; export function registerObjectsCommand(program: Command) { @@ -30,6 +40,12 @@ export function registerObjectsCommand(program: Command) { .action(async (objectId: string, options: Record) => { await getObject(program, objectId, options); }); + store.command('text ') + .description("Get the extracted text for an existing object") + .option('--json', 'Print raw JSON instead of plain text') + .action(async (objectId: string, options: Record) => { + await getObjectText(program, objectId, options); + }); store.command('download ') .description("Download an object's content to a file") .option('-o, --output [path]', 'Output file path (defaults to object name)') @@ -40,7 +56,25 @@ export function registerObjectsCommand(program: Command) { .description("List the objects inside a folder. If no folder is specified all the objects are listed.") .option('-l,--limit [limit]', 'Limit the number of objects returned. The default limit is 100. Useful for pagination.') .option('-s,--skip [skip]', 'Skip the number of objects to skip. Default is 0. Useful for pagination.') + .option('--json', 'Print raw JSON') .action(async (folderPath: string | undefined, options: Record) => { await listObjects(program, folderPath, options); }); + store.command('search ') + .description("Full-text search across stored content objects") + .option('-l,--limit [limit]', 'Limit the number of results returned. Default is 20.') + .option('--type [type]', 'Filter by object type id or code') + .option('--path [path]', 'Filter by object location/path') + .option('--select [fields]', 'Selection string for returned fields') + .option('--json', 'Print raw JSON') + .action(async (query: string, options: Record) => { + await searchObjects(program, query, options); + }); + store.command('query') + .description("Query indexed documents using raw Elasticsearch DSL") + .option('--dsl [json]', 'Raw Elasticsearch DSL as a JSON string') + .option('--json', 'Print raw JSON') + .action(async (options: Record) => { + await queryObjects(program, options); + }); } diff --git a/packages/cli/src/profiles/commands.ts b/packages/cli/src/profiles/commands.ts index 737519f12..7ee206428 100644 --- a/packages/cli/src/profiles/commands.ts +++ b/packages/cli/src/profiles/commands.ts @@ -1,3 +1,4 @@ +import { VertesiaClient } from '@vertesia/client'; import colors from 'ansi-colors'; import enquirer from "enquirer"; import jwt from 'jsonwebtoken'; @@ -7,6 +8,16 @@ const { prompt } = enquirer; export type OnResultCallback = (result: ConfigResult | undefined) => void | Promise; +interface CliPromptQuestion { + type: string; + name: string; + message: string; + choices?: string[]; + initial?: string | number | boolean; + format?: (value: string) => string; + validate?: (value: string) => boolean | string; +} + export async function listProfiles() { const selected = config.current?.name; @@ -16,7 +27,7 @@ export async function listProfiles() { if (!config.profiles.length) { console.log("No profiles are defined. Run `vertesia profiles add` to add a new profile."); console.log(); - const r: any = await prompt({ + const r = await prompt<{ create?: boolean }>({ type: "confirm", name: 'create', message: "Do you want to create a profile now?", @@ -55,7 +66,7 @@ export function showProfile(name?: string) { } } -export function showActiveAuthToken() { +export async function showActiveAuthToken() { if (config.profiles.length === 0) { console.log('No profiles are defined. Run `vertesia profiles create` to add a new profile.'); return; @@ -63,7 +74,7 @@ export function showActiveAuthToken() { const token = jwt.decode(config.current.apikey, { json: true }); if (token?.exp && token.exp * 1000 < Date.now()) { console.log("Authentication token expired. Create a new one "); - _doRefreshToken(config.current.name); + await _doRefreshToken(config.current.name); } else { console.log(config.current.apikey); } @@ -77,7 +88,7 @@ export function deleteProfile(name: string) { config.remove(name).save(); } -interface CreateProfileOptions { +export interface CreateProfileOptions { target?: string, region?: string, apikey?: string, @@ -87,7 +98,7 @@ interface CreateProfileOptions { } export async function createProfile(name?: string, options: CreateProfileOptions = {}) { const format = (value: string) => value.trim(); - const questions: any[] = []; + const questions: CliPromptQuestion[] = []; if (!name) { questions.push({ type: 'input', @@ -120,7 +131,7 @@ export async function createProfile(name?: string, options: CreateProfileOptions let target = options.target === "production" ? "prod" : options.target; if (questions.length > 0) { - const response: any = await prompt(questions) + const response = await prompt<{ name?: string; target?: string }>(questions); if (!name) { name = response.name; } @@ -136,7 +147,7 @@ export async function createProfile(name?: string, options: CreateProfileOptions // If custom target, prompt for URL if (target === 'custom') { - const customResponse: any = await prompt({ + const customResponse = await prompt<{ url?: string }>({ type: 'input', name: 'url', message: 'Enter the custom URL (e.g., https://your-deployment.vercel.app/cli)', @@ -148,40 +159,55 @@ export async function createProfile(name?: string, options: CreateProfileOptions return true; } }); - target = customResponse.url.trim(); + const customUrl = customResponse.url?.trim(); + if (!customUrl) { + console.error("Invalid target URL"); + process.exit(1); + } + target = customUrl; } // Prompt for region when target requires it (preview/prod only) - let region: Region = (options.region as Region) ?? DEFAULT_REGION; + const selectedRegion = readRegion(options.region); + if (options.region && !selectedRegion) { + console.error(`Invalid region "${options.region}". Expected one of: ${AVAILABLE_REGIONS.join(', ')}`); + process.exit(1); + } + let region = selectedRegion ?? DEFAULT_REGION; const needsRegionPrompt = !options.region && (target === 'preview' || target === 'prod'); if (needsRegionPrompt) { - const regionResponse: any = await prompt({ + const regionQuestion: CliPromptQuestion = { type: 'select', name: 'region', message: 'Deployment region', choices: AVAILABLE_REGIONS, initial: AVAILABLE_REGIONS[0], - } as any); - region = regionResponse.region as Region; + }; + const regionResponse = await prompt<{ region?: string }>(regionQuestion); + region = readRegion(regionResponse.region) ?? DEFAULT_REGION; } if (options.apikey) { - if (!options.account || !options.project) { - console.error("When using --apikey you must provide the project and account IDs"); + const serverUrls = getServerUrls(target!, region); + const tokenRefs = await resolveCredentialRefs(options.apikey, serverUrls); + const account = options.account || tokenRefs.account; + const project = options.project || tokenRefs.project; + if (!account || !project) { + console.error("Unable to resolve project and account from the supplied credential. Check the target endpoint or provide --project and --account."); process.exit(1); } config.add({ - account: options.account, - project: options.project, + account, + project, name, config_url: getConfigUrl(target!, region), apikey: options.apikey, region, - ...getServerUrls(target!, region), + ...serverUrls, }); config.use(name!).save(); } else { - config.createProfile(name!, target!, region).start(options.onResult); + await config.createProfile(name!, target!, region).start(options.onResult); } return name!; @@ -196,26 +222,94 @@ export async function updateProfile(name?: string, onResult?: OnResultCallback, console.error(`Profile ${name} not found`); process.exit(1); } - config.updateProfile(name).start(onResult, signal); + await config.updateProfile(name).start(onResult, signal); } -export function updateCurrentProfile(onResult?: OnResultCallback, signal?: AbortSignal) { +export function updateCurrentProfile(onResult?: OnResultCallback, signal?: AbortSignal): Promise { if (!config.current) { console.log("No profile is selected. Run `vertesia profiles use ` to select a profile"); process.exit(1); } - config.updateProfile(config.current.name).start(onResult, signal); + return config.updateProfile(config.current.name).start(onResult, signal); } async function selectProfile(message = "Select the profile") { - const response: any = await prompt({ + const question: CliPromptQuestion = { type: 'select', name: 'name', message, choices: config.profiles.map(p => p.name) - }) - return response.name as string; + }; + const response = await prompt<{ name?: string }>(question); + if (!response.name) { + console.error("No profile selected"); + process.exit(1); + } + return response.name; +} + +function readRegion(value: string | undefined): Region | undefined { + return AVAILABLE_REGIONS.find(region => region === value); +} + +interface CredentialRefs { + account?: string; + project?: string; +} + +async function resolveCredentialRefs(credential: string, serverUrls: { studio_server_url: string; zeno_server_url: string }): Promise { + const tokenRefs = readTokenRefs(credential); + if (tokenRefs.account && tokenRefs.project) { + return tokenRefs; + } + + const client = new VertesiaClient({ + serverUrl: serverUrls.studio_server_url, + storeUrl: serverUrls.zeno_server_url, + apikey: credential, + }); + const [account, project] = await Promise.all([ + client.getAccount(), + client.getProject(), + ]); + + return { + account: tokenRefs.account || account?.id, + project: tokenRefs.project || project?.id, + }; +} + +function readTokenRefs(token: string): CredentialRefs { + const decoded = jwt.decode(token, { json: true }); + if (!decoded || typeof decoded !== 'object') { + return {}; + } + return { + account: readRefId(decoded, 'account') || readStringField(decoded, 'account_id'), + project: readRefId(decoded, 'project') || readStringField(decoded, 'project_id'), + }; +} + +function readRefId(value: object, key: string): string | undefined { + const field = Reflect.get(value, key); + if (typeof field === 'string') { + return field; + } + if (!isRecord(field)) { + return undefined; + } + const id = Reflect.get(field, 'id'); + return typeof id === 'string' ? id : undefined; +} + +function readStringField(value: object, key: string): string | undefined { + const field = Reflect.get(value, key); + return typeof field === 'string' ? field : undefined; +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); } export async function tryRefreshToken() { @@ -227,18 +321,31 @@ export async function tryRefreshToken() { console.log(); console.log(colors.bold("Operation Failed:"), colors.red("Authentication token expired!")); console.log(); - _doRefreshToken(config.current.name); + await _doRefreshToken(config.current.name); } } async function _doRefreshToken(profileName: string, onResult?: OnResultCallback) { - const r: any = await prompt({ - name: 'refresh', - type: "confirm", - message: "Do you want to refresh the token for the current profile?", - initial: true, - }) - if (r.refresh) { - config.updateProfile(profileName).start(onResult); - } -} \ No newline at end of file + const abortController = new AbortController(); + const handleSignal = () => { + abortController.abort(); + console.log("\nToken refresh interrupted"); + process.exit(130); + }; + process.once('SIGINT', handleSignal); + process.once('SIGTERM', handleSignal); + try { + const r = await prompt<{ refresh?: boolean }>({ + name: 'refresh', + type: "confirm", + message: "Do you want to refresh the token for the current profile?", + initial: true, + }); + if (r.refresh) { + await config.updateProfile(profileName).start(onResult, abortController.signal); + } + } finally { + process.off('SIGINT', handleSignal); + process.off('SIGTERM', handleSignal); + } +} diff --git a/packages/cli/src/profiles/server/index.ts b/packages/cli/src/profiles/server/index.ts index 04b578362..9802a8cfb 100644 --- a/packages/cli/src/profiles/server/index.ts +++ b/packages/cli/src/profiles/server/index.ts @@ -1,7 +1,6 @@ import { randomInt } from "crypto"; import enquirer from "enquirer"; import { Server } from "http"; -import net from "net"; import open from "open"; import { handleCors } from "./cors.js"; import { readRequestBody, startServer } from "./server.js"; @@ -22,9 +21,9 @@ export interface ConfigResult extends Required { export async function startConfigSession( - config_url: string, - payload: ConfigPayload, - callback: (response: ConfigResult | undefined) => void, + config_url: string, + payload: ConfigPayload, + callback: (response: ConfigResult | undefined) => void | Promise, signal?: AbortSignal ) { if (!config_url) { @@ -32,29 +31,64 @@ export async function startConfigSession( console.error("Please, delete the profile and create it again."); process.exit(1); } - - // Check if already aborted + let server: Server | undefined; + let completed = false; + + function closeServer() { + if (server?.listening) { + server.close(); + } + } + + function cleanup() { + closeServer(); + if (signal) { + signal.removeEventListener('abort', onAbort); + } else { + process.off('SIGINT', onInterrupt); + process.off('SIGTERM', onInterrupt); + } + } + + async function complete(result: ConfigResult) { + if (completed) { + return; + } + completed = true; + cleanup(); + await callback(result); + } + + function onAbort() { + if (completed) { + return; + } + completed = true; + cleanup(); + } + + function onInterrupt() { + if (!completed) { + completed = true; + cleanup(); + } + console.log("\nAuthentication interrupted."); + process.exit(130); + } + if (signal?.aborted) { - callback(undefined); return; } - - // Handle abort signal - const onAbort = () => { - // Clean up and exit - if (server) { - server.close(); - } - callback(undefined); - }; - + if (signal) { signal.addEventListener('abort', onAbort, { once: true }); + } else { + process.once('SIGINT', onInterrupt); + process.once('SIGTERM', onInterrupt); } - + const code = randomInt(1000, 9999); - let server: Server; - + try { server = await startServer(async (req, res) => { if (handleCors(req, res)) { @@ -71,34 +105,33 @@ export async function startConfigSession( res.end(); return; } - + try { const data = await readRequestBody(req); - const result = JSON.parse(data as string); + if (typeof data !== 'string') { + throw new Error('Invalid callback payload'); + } + const result = readConfigResult(data); res.statusCode = 200; res.end(); - // Check if signal is aborted before proceeding - if (signal?.aborted) { + if (signal?.aborted || completed) { return; } - - await callback(result); - - // Clean up signal listener - if (signal) { - signal.removeEventListener('abort', onAbort); - } - - server.close(); // close the server + + await complete(result); } catch (error) { res.statusCode = 500; res.end(); console.error("Error processing request:", error); } }); - - const port = (server.address() as net.AddressInfo).port; + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to determine local callback port'); + } + const port = address.port; const params = new URLSearchParams(); params.append('redirect_uri', `http://localhost:${port}`); params.append('code', String(code)); @@ -106,59 +139,50 @@ export async function startConfigSession( if (payload.account) params.append('account', payload.account); if (payload.project) params.append('project', payload.project); const url = `${config_url}?${params.toString()}`; - + console.log("Opening browser to", url); - open(url); + open(url).catch(error => { + console.error("Unable to open browser:", error instanceof Error ? error.message : String(error)); + }); console.log(`The session code is ${code}`); - + // Handle manual token entry try { // Check if already aborted - if (signal?.aborted) { + if (signal?.aborted || completed) { return; } - - const answer: any = await prompt({ + + const answer = await prompt<{ result?: string }>({ name: 'result', type: 'input', message: "The browser failed to send the token? Copy the token here", }); - + // Check if aborted after prompt - if (signal?.aborted) { + if (signal?.aborted || completed) { return; } - + const resultText = answer.result?.trim(); if (resultText) { try { - const result = JSON.parse(answer.result.trim()); - - // Clean up signal listener before callback - if (signal) { - signal.removeEventListener('abort', onAbort); - } - - await callback(result); - console.log('Authentication completed.'); - } catch (e) { + await complete(readConfigResult(resultText)); + } catch { console.error("Invalid token"); process.exit(1); } } - } catch (err: any) { + } catch (err: unknown) { // This could be thrown if the prompt is interrupted if (signal?.aborted) { return; } throw err; } - } catch (err: any) { - // Clean up signal listener on error - if (signal) { - signal.removeEventListener('abort', onAbort); - } - + } catch (err: unknown) { + cleanup(); + // Only throw if not aborted if (!signal?.aborted) { throw err; @@ -166,4 +190,28 @@ export async function startConfigSession( } } +function readConfigResult(raw: string): ConfigResult { + const parsed: unknown = JSON.parse(raw); + if (!isConfigResult(parsed)) { + throw new Error('Invalid callback payload'); + } + return parsed; +} + +function isConfigResult(value: unknown): value is ConfigResult { + if (!value || typeof value !== 'object') { + return false; + } + return hasStringField(value, 'profile') + && hasStringField(value, 'account') + && hasStringField(value, 'project') + && hasStringField(value, 'studio_server_url') + && hasStringField(value, 'zeno_server_url') + && hasStringField(value, 'token'); +} + +function hasStringField(value: object, key: string): boolean { + return typeof Reflect.get(value, key) === 'string'; +} + //startConfigSession("https://localhost:5173/cli", {}, (result: ConfigResult) => console.log("Logged in", result)); diff --git a/packages/cli/src/profiles/server/server.ts b/packages/cli/src/profiles/server/server.ts index 627411bf9..69918359f 100644 --- a/packages/cli/src/profiles/server/server.ts +++ b/packages/cli/src/profiles/server/server.ts @@ -2,13 +2,6 @@ import { Server, createServer, ServerResponse, IncomingMessage } from 'http'; export async function startServer(cb: (req: IncomingMessage, res: ServerResponse) => void): Promise { const server = createServer(cb); - const onSigint = () => { - server.close(); - } - server.on('close', () => { - process.off('SIGINT', onSigint); - }); - process.on('SIGINT', onSigint); // start the server on a random unused port return new Promise((resolve, reject) => { @@ -32,4 +25,4 @@ export function readRequestBody(request: IncomingMessage) { });; }); -} \ No newline at end of file +} diff --git a/packages/client/src/store/AgentsApi.ts b/packages/client/src/store/AgentsApi.ts index 8acc2cd0b..e2b8b01ef 100644 --- a/packages/client/src/store/AgentsApi.ts +++ b/packages/client/src/store/AgentsApi.ts @@ -6,6 +6,7 @@ import { AgentMessageType, AgentRun, AgentRunArchiveState, + AgentRunResponse, AgentRunInternals, AgentRunStatus, BindRunWorkflowPayload, @@ -88,6 +89,15 @@ export class AgentsApi extends ApiTopic { return this.get(`/${id}`); } + /** + * Get any agent run by id, preserving the agent/process discriminator. + */ + retrieveRun, TProperties = Record>( + id: string, + ): Promise> { + return this.get(`/${id}`); + } + retrieveProcess(id: string): Promise { return this.get(`/${id}`); } From 0057d44e267c70ab82c60b0ec9f5e7beb21d5e6a Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Wed, 22 Apr 2026 04:14:24 +0900 Subject: [PATCH 13/75] feat: update documentation for listChildren and getChildDetails methods; add child run identifiers to NodeHistoryEntry interface --- packages/client/src/store/AgentsApi.ts | 4 ++-- packages/common/src/store/process.ts | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/client/src/store/AgentsApi.ts b/packages/client/src/store/AgentsApi.ts index e2b8b01ef..ac17364b2 100644 --- a/packages/client/src/store/AgentsApi.ts +++ b/packages/client/src/store/AgentsApi.ts @@ -592,7 +592,7 @@ export class AgentsApi extends ApiTopic { } /** - * List child workflows (sub-agents) for an agent run. + * List child workflows for an agent or process run. */ listChildren(id: string): Promise { return this.get(`/${id}/children`); @@ -600,7 +600,7 @@ export class AgentsApi extends ApiTopic { /** * Get details for a specific child workflow. - * Serves from GCS archive when available, falls back to Temporal. + * Serves from the child run record when available, falls back to archive or Temporal. */ getChildDetails( id: string, diff --git a/packages/common/src/store/process.ts b/packages/common/src/store/process.ts index 6ecb95e08..1cf98c4ed 100644 --- a/packages/common/src/store/process.ts +++ b/packages/common/src/store/process.ts @@ -169,6 +169,9 @@ export interface NodeHistoryEntry { context_diff: Record; data_ref?: string; sequence?: number; + child_run_id?: string; + child_workflow_id?: string; + child_workflow_run_id?: string; } export interface ProcessHistoryRef { From 0a4685c5f36db41a27ba8cf25c38c4c240a8f84e Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Wed, 22 Apr 2026 21:32:51 +0900 Subject: [PATCH 14/75] feat: implement keyring support for profile authentication; add stream termination logic and tests --- packages/cli/package.json | 3 + packages/cli/src/client.ts | 14 +- packages/cli/src/profiles/commands.ts | 25 +++- packages/cli/src/profiles/index.ts | 75 +++++++++- packages/cli/src/profiles/keyring.ts | 139 ++++++++++++++++++ packages/cli/src/profiles/server/index.ts | 4 + packages/client/src/store/AgentsApi.ts | 14 +- packages/client/src/store/WorkflowsApi.ts | 17 +-- .../src/store/stream-termination.test.ts | 58 ++++++++ .../client/src/store/stream-termination.ts | 40 +++++ 10 files changed, 350 insertions(+), 39 deletions(-) create mode 100644 packages/cli/src/profiles/keyring.ts create mode 100644 packages/client/src/store/stream-termination.test.ts create mode 100644 packages/client/src/store/stream-termination.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 0b27a0ef9..1ec2a149f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -61,6 +61,9 @@ "signal-exit": "^4.1.0", "typescript": "^6.0.2" }, + "optionalDependencies": { + "@napi-rs/keyring": "^1.2.0" + }, "devDependencies": { "@eslint/js": "^10.0.1", "@types/jsonwebtoken": "^9.0.10", diff --git a/packages/cli/src/client.ts b/packages/cli/src/client.ts index 34a63310f..90dd3287e 100644 --- a/packages/cli/src/client.ts +++ b/packages/cli/src/client.ts @@ -1,6 +1,7 @@ import { VertesiaClient } from "@vertesia/client"; import { Command } from "commander"; import { config, Profile } from "./profiles/index.js"; +import { isKeyringAvailable, readProfileAccessToken } from "./profiles/keyring.js"; let _client: VertesiaClient | undefined; @@ -21,6 +22,8 @@ export async function getClient(_program?: Command): Promise { } async function createClient(profile: Profile | undefined): Promise { + const token = process.env.VERTESIA_TOKEN; + // Explicit environment overrides should win over profile settings so the same // credential can be reused against a local deployment without changing profiles. const env = { @@ -37,9 +40,6 @@ async function createClient(profile: Profile | undefined): Promise { + if (profile.apikey && !hasStoredAccessToken(profile.name)) { + return profile; + } + const { apikey, ...safeProfile } = profile; + void apikey; + return safeProfile; + }), }); return this; } @@ -296,11 +317,33 @@ export class Config { try { const data = readJsonFile(getConfigFile('profiles.json')) as ProfilesData; this.profiles = data.profiles; + let needsSave = false; + if (isKeyringAvailable()) { + for (const profile of this.profiles) { + if (!profile.apikey) { + continue; + } + const existingBundle = readAuthBundle(profile.name); + if (!existingBundle?.accessToken) { + writeAuthBundle(profile.name, { + accessToken: profile.apikey, + accessTokenExpiresAt: readInlineTokenExpiry(profile.apikey), + refreshToken: existingBundle?.refreshToken, + refreshTokenExpiresAt: existingBundle?.refreshTokenExpiresAt, + }); + } + delete profile.apikey; + needsSave = true; + } + } if (data.default) { this.use(data.default) } else { this.current = undefined; } + if (needsSave) { + this.save(); + } } catch (err: any) { if (err.code !== 'ENOENT') { throw err; @@ -334,3 +377,21 @@ export class InvalidConfigUrlError extends Error { const config = new Config().load(); export { config }; + +function readInlineTokenExpiry(token: string): number | undefined { + const decoded = jwt.decode(token, { json: true }); + if (!decoded?.exp) { + return undefined; + } + return decoded.exp * 1000; +} + +function readResultAccessTokenExpiry(result: ConfigResult): number | undefined { + if (typeof result.access_token_expires_at === 'number') { + return result.access_token_expires_at; + } + if (typeof result.expires_in === 'number') { + return Date.now() + result.expires_in * 1000; + } + return readInlineTokenExpiry(result.token); +} diff --git a/packages/cli/src/profiles/keyring.ts b/packages/cli/src/profiles/keyring.ts new file mode 100644 index 000000000..b40204cca --- /dev/null +++ b/packages/cli/src/profiles/keyring.ts @@ -0,0 +1,139 @@ +import jwt from 'jsonwebtoken'; +import { createRequire } from 'node:module'; +import type { Profile } from './index.js'; + +const require = createRequire(import.meta.url); + +const KEYRING_SERVICE = 'vertesia-cli'; +const AUTH_BUNDLE_VERSION = 1; +const KEYRING_UNAVAILABLE_MESSAGE = 'Native keyring is required for Vertesia CLI profile authentication.'; + +interface KeyringModule { + Entry: new (service: string, account: string) => { + getPassword(): string | null; + setPassword(password: string): void; + deletePassword(): void; + }; +} + +export interface StoredAuthBundle { + version: number; + accessToken?: string; + accessTokenExpiresAt?: number; + refreshToken?: string; + refreshTokenExpiresAt?: number; +} + +type WritableAuthBundle = Omit; + +let cachedKeyringModule: KeyringModule | null | undefined; + +function getKeyringModule(): KeyringModule | undefined { + if (cachedKeyringModule !== undefined) { + return cachedKeyringModule ?? undefined; + } + try { + cachedKeyringModule = require('@napi-rs/keyring') as KeyringModule; + } catch { + cachedKeyringModule = null; + } + return cachedKeyringModule ?? undefined; +} + +export function isKeyringAvailable(): boolean { + return !!getKeyringModule(); +} + +function getEntry(profileName: string) { + const keyring = getKeyringModule(); + if (!keyring) { + throw new Error(KEYRING_UNAVAILABLE_MESSAGE); + } + return new keyring.Entry(KEYRING_SERVICE, profileName); +} + +function readRaw(profileName: string): string | null { + if (!isKeyringAvailable()) { + return null; + } + try { + return getEntry(profileName).getPassword(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes('not found') || message.includes('No such') || message.includes('not exist')) { + return null; + } + throw error; + } +} + +export function readAuthBundle(profileName: string): StoredAuthBundle | undefined { + const raw = readRaw(profileName); + if (!raw) { + return undefined; + } + const parsed: unknown = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`Invalid keyring payload for profile "${profileName}"`); + } + + const bundle = parsed as StoredAuthBundle; + if (bundle.version !== AUTH_BUNDLE_VERSION) { + throw new Error(`Unsupported auth bundle version for profile "${profileName}"`); + } + return bundle; +} + +export function writeAuthBundle(profileName: string, bundle: WritableAuthBundle) { + const payload: StoredAuthBundle = { + version: AUTH_BUNDLE_VERSION, + accessToken: bundle.accessToken, + accessTokenExpiresAt: bundle.accessTokenExpiresAt, + refreshToken: bundle.refreshToken, + refreshTokenExpiresAt: bundle.refreshTokenExpiresAt, + }; + getEntry(profileName).setPassword(JSON.stringify(payload)); +} + +export function deleteAuthBundle(profileName: string) { + if (!isKeyringAvailable()) { + return; + } + try { + getEntry(profileName).deletePassword(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes('not found') || message.includes('No such') || message.includes('not exist')) { + return; + } + throw error; + } +} + +export function readProfileAccessToken(profile: Pick): string | undefined { + const bundle = readAuthBundle(profile.name); + return bundle?.accessToken || profile.apikey; +} + +export function readProfileRefreshToken(profileName: string): string | undefined { + return readAuthBundle(profileName)?.refreshToken; +} + +export function getAccessTokenExpiry(token: string | undefined): number | undefined { + if (!token) { + return undefined; + } + const decoded = jwt.decode(token, { json: true }); + if (!decoded?.exp) { + return undefined; + } + return decoded.exp * 1000; +} + +export function hasStoredAccessToken(profileName: string): boolean { + return Boolean(readAuthBundle(profileName)?.accessToken); +} + +export function hasStoredRefreshToken(profileName: string): boolean { + return Boolean(readAuthBundle(profileName)?.refreshToken); +} diff --git a/packages/cli/src/profiles/server/index.ts b/packages/cli/src/profiles/server/index.ts index 9802a8cfb..69ff6352e 100644 --- a/packages/cli/src/profiles/server/index.ts +++ b/packages/cli/src/profiles/server/index.ts @@ -17,6 +17,10 @@ export interface ConfigResult extends Required { studio_server_url: string; zeno_server_url: string; token: string; + refresh_token?: string; + expires_in?: number; + access_token_expires_at?: number; + refresh_token_expires_at?: number; } diff --git a/packages/client/src/store/AgentsApi.ts b/packages/client/src/store/AgentsApi.ts index ac17364b2..32c847d81 100644 --- a/packages/client/src/store/AgentsApi.ts +++ b/packages/client/src/store/AgentsApi.ts @@ -3,7 +3,6 @@ import { ActiveWorkstreamsQueryResult, AgentEvent, AgentMessage, - AgentMessageType, AgentRun, AgentRunArchiveState, AgentRunResponse, @@ -44,6 +43,7 @@ import { } from '@vertesia/common'; import { VertesiaClient } from '../client.js'; import { EventSourceProvider } from '../execute.js'; +import { shouldCloseAgentRunStream, shouldCloseCompactRunStream } from './stream-termination.js'; export class AgentsApi extends ApiTopic { constructor(parent: ClientBase) { @@ -360,11 +360,7 @@ export class AgentsApi extends ApiTopic { if (onMessage) onMessage(msg, exit); if (isClosed) break; - const workstreamId = msg.workstream_id || 'main'; - const streamIsOver = - msg.type === AgentMessageType.TERMINATED || - (msg.type === AgentMessageType.COMPLETE && workstreamId === 'main'); - if (streamIsOver) { + if (shouldCloseAgentRunStream(msg, id)) { exit(null); return promise; } @@ -435,11 +431,7 @@ export class AgentsApi extends ApiTopic { onMessage(agentMessage, exit); } - const workstreamId = compactMessage.w || 'main'; - const streamIsOver = - compactMessage.t === AgentMessageType.TERMINATED || - (compactMessage.t === AgentMessageType.COMPLETE && workstreamId === 'main'); - if (streamIsOver) { + if (shouldCloseCompactRunStream(compactMessage, id)) { exit(null); } } catch (err) { diff --git a/packages/client/src/store/WorkflowsApi.ts b/packages/client/src/store/WorkflowsApi.ts index 2ac20ce92..1a693b2a0 100644 --- a/packages/client/src/store/WorkflowsApi.ts +++ b/packages/client/src/store/WorkflowsApi.ts @@ -39,6 +39,7 @@ import { } from "@vertesia/common"; import { VertesiaClient } from "../client.js"; import { EventSourceProvider } from "../execute.js"; +import { shouldCloseAgentRunStream, shouldCloseCompactRunStream } from "./stream-termination.js"; export class WorkflowsApi extends ApiTopic { constructor(parent: ClientBase) { @@ -257,14 +258,7 @@ export class WorkflowsApi extends ApiTopic { onMessage(agentMessage, exit); } - // Get workstream ID (defaults to 'main' if not set) - const workstreamId = compactMessage.w || 'main'; - - const streamIsOver = compactMessage.t === AgentMessageType.TERMINATED || - (compactMessage.t === AgentMessageType.COMPLETE && workstreamId === 'main'); - - // Only close the stream when the main workstream completes or terminates - if (streamIsOver) { + if (shouldCloseCompactRunStream(compactMessage, runId)) { console.log("Closing stream due to COMPLETE message from main workstream"); if (!isClosed) { isClosed = true; @@ -272,7 +266,7 @@ export class WorkflowsApi extends ApiTopic { resolve(null); } } else if (compactMessage.t === AgentMessageType.COMPLETE) { - console.log(`Received COMPLETE message from non-main workstream: ${workstreamId}, keeping stream open`); + console.log(`Received COMPLETE message that does not close the root stream for run ${runId}`); } } catch (err) { console.error("Failed to parse SSE message:", err, ev.data); @@ -328,10 +322,7 @@ export class WorkflowsApi extends ApiTopic { if (onMessage) { onMessage(msg, exit); } - const workstreamId = msg.workstream_id || 'main'; - const streamIsOver = msg.type === AgentMessageType.TERMINATED || - (msg.type === AgentMessageType.COMPLETE && workstreamId === 'main'); - if (streamIsOver) { + if (shouldCloseAgentRunStream(msg, runId)) { resolve(null); return; } diff --git a/packages/client/src/store/stream-termination.test.ts b/packages/client/src/store/stream-termination.test.ts new file mode 100644 index 000000000..a1cc93f9f --- /dev/null +++ b/packages/client/src/store/stream-termination.test.ts @@ -0,0 +1,58 @@ +import { AgentMessageType, type AgentMessage, type CompactMessage } from "@vertesia/common"; +import { describe, expect, it } from "vitest"; +import { shouldCloseAgentRunStream, shouldCloseCompactRunStream } from "./stream-termination.js"; + +describe("stream termination", () => { + const rootRunId = "root-run"; + + it("closes agent streams on the root run completion", () => { + const message: AgentMessage = { + type: AgentMessageType.COMPLETE, + timestamp: Date.now(), + workflow_run_id: rootRunId, + message: "root complete", + workstream_id: "main", + details: { + process_run_id: rootRunId, + }, + }; + + expect(shouldCloseAgentRunStream(message, rootRunId)).toBe(true); + }); + + it("keeps agent streams open for child process completion on main", () => { + const message: AgentMessage = { + type: AgentMessageType.COMPLETE, + timestamp: Date.now(), + workflow_run_id: rootRunId, + message: "child complete", + workstream_id: "main", + details: { + process_run_id: "child-run", + }, + }; + + expect(shouldCloseAgentRunStream(message, rootRunId)).toBe(false); + }); + + it("keeps compact streams open for child process completion on main", () => { + const message: CompactMessage = { + t: AgentMessageType.COMPLETE, + m: "child complete", + d: { + process_run_id: "child-run", + }, + }; + + expect(shouldCloseCompactRunStream(message, rootRunId)).toBe(false); + }); + + it("still closes streams for main completion without process metadata", () => { + const message: CompactMessage = { + t: AgentMessageType.COMPLETE, + m: "conversation complete", + }; + + expect(shouldCloseCompactRunStream(message, rootRunId)).toBe(true); + }); +}); diff --git a/packages/client/src/store/stream-termination.ts b/packages/client/src/store/stream-termination.ts new file mode 100644 index 000000000..a1275ac1e --- /dev/null +++ b/packages/client/src/store/stream-termination.ts @@ -0,0 +1,40 @@ +import { AgentMessage, AgentMessageType, CompactMessage } from "@vertesia/common"; + +type MessageDetails = AgentMessage["details"] | CompactMessage["d"]; + +function readProcessRunId(details: MessageDetails): string | undefined { + if (!details || typeof details !== "object") { + return undefined; + } + const value = (details as { process_run_id?: unknown }).process_run_id; + return typeof value === "string" ? value : undefined; +} + +function isRootProcessMessage(details: MessageDetails, rootRunId: string): boolean { + const processRunId = readProcessRunId(details); + return !processRunId || processRunId === rootRunId; +} + +export function shouldCloseAgentRunStream(message: AgentMessage, rootRunId: string): boolean { + if (message.type === AgentMessageType.TERMINATED) { + return isRootProcessMessage(message.details, rootRunId); + } + + if (message.type === AgentMessageType.COMPLETE && (message.workstream_id ?? "main") === "main") { + return isRootProcessMessage(message.details, rootRunId); + } + + return false; +} + +export function shouldCloseCompactRunStream(message: CompactMessage, rootRunId: string): boolean { + if (message.t === AgentMessageType.TERMINATED) { + return isRootProcessMessage(message.d, rootRunId); + } + + if (message.t === AgentMessageType.COMPLETE && (message.w ?? "main") === "main") { + return isRootProcessMessage(message.d, rootRunId); + } + + return false; +} From e2728710b7056a8d7cf6bb8889469116888a366a Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Fri, 24 Apr 2026 14:02:23 +0900 Subject: [PATCH 15/75] oauth in cli --- llumiverse | 2 +- packages/cli/src/agents/index.ts | 2 + packages/cli/src/client.ts | 12 +- packages/cli/src/profiles/auth.ts | 65 +++ packages/cli/src/profiles/commands.ts | 35 +- packages/cli/src/profiles/index.ts | 65 ++- packages/cli/src/profiles/keyring.ts | 4 + packages/cli/src/profiles/oauth.ts | 434 ++++++++++++++++++ packages/cli/src/profiles/server/index.ts | 2 + packages/cli/src/worker/connect.ts | 6 +- packages/cli/src/worker/refresh.ts | 4 +- .../common/src/store/process-schema.test.ts | 22 +- packages/common/src/store/process-schema.ts | 90 +++- .../src/store/process-validation.test.ts | 68 ++- .../common/src/store/process-validation.ts | 141 +++++- packages/common/src/store/process.ts | 27 +- 16 files changed, 887 insertions(+), 92 deletions(-) create mode 100644 packages/cli/src/profiles/auth.ts create mode 100644 packages/cli/src/profiles/oauth.ts diff --git a/llumiverse b/llumiverse index 4a5bc6611..aec993efc 160000 --- a/llumiverse +++ b/llumiverse @@ -1 +1 @@ -Subproject commit 4a5bc6611fae911f040729feb606ae07dfb443e7 +Subproject commit aec993efc836524e951373a83a510a8857c79e84 diff --git a/packages/cli/src/agents/index.ts b/packages/cli/src/agents/index.ts index 0c95b7d47..7a80ca45d 100644 --- a/packages/cli/src/agents/index.ts +++ b/packages/cli/src/agents/index.ts @@ -5,6 +5,7 @@ import { CreateAgentRunPayload, CreateProcessRunPayload, InteractionExecutionConfiguration, + PROCESS_DEFINITION_FORMAT_VERSION, ProcessDefinitionBody, ProcessRun, ProcessRunConfig, @@ -627,6 +628,7 @@ function isRecord(value: unknown): value is Record { function isProcessDefinitionBody(value: unknown): value is ProcessDefinitionBody { return isRecord(value) + && value.format_version === PROCESS_DEFINITION_FORMAT_VERSION && typeof value.process === "string" && typeof value.initial === "string" && isRecord(value.context) diff --git a/packages/cli/src/client.ts b/packages/cli/src/client.ts index 90dd3287e..5fb690dcc 100644 --- a/packages/cli/src/client.ts +++ b/packages/cli/src/client.ts @@ -1,7 +1,8 @@ import { VertesiaClient } from "@vertesia/client"; import { Command } from "commander"; import { config, Profile } from "./profiles/index.js"; -import { isKeyringAvailable, readProfileAccessToken } from "./profiles/keyring.js"; +import { ensureProfileAccessToken } from "./profiles/auth.js"; +import { isKeyringAvailable } from "./profiles/keyring.js"; let _client: VertesiaClient | undefined; @@ -61,9 +62,12 @@ async function createClient(profile: Profile | undefined): Promise { + const token = readProfileAccessToken(profile); + if (token && !shouldRefreshProfileToken(profile, 30)) { + return token; + } + + const result = await refreshProfileAccessToken(profile, onResult); + return result?.token; +} + +export async function refreshProfileAccessToken(profile: Profile, onResult?: OnResultCallback): Promise { + const bundle = readAuthBundle(profile.name); + if (!bundle?.refreshToken || !canUseOAuthProfile(profile)) { + return undefined; + } + + const result = await refreshOAuthSession(profile, bundle.refreshToken, bundle); + const updater = config.updateProfile(profile.name); + updater.onResultCallback = onResult; + await updater.persistConfigResult(result); + return result; +} + +export async function refreshProfileAuthentication( + profileName: string, + onResult?: OnResultCallback, + signal?: AbortSignal, +): Promise { + const profile = config.getProfile(profileName); + if (!profile) { + throw new Error(`Profile ${profileName} not found.`); + } + + try { + const refreshed = await refreshProfileAccessToken(profile, onResult); + if (refreshed) { + return refreshed; + } + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + console.error('Falling back to interactive authentication.'); + } + + const updater = config.updateProfile(profileName); + await updater.start(onResult, signal); + return undefined; +} + +export async function refreshCurrentProfileAuthentication( + onResult?: OnResultCallback, + signal?: AbortSignal, +): Promise { + if (!config.current) { + console.log("No profile is selected. Run `vertesia profiles use ` to select a profile"); + process.exit(1); + } + return refreshProfileAuthentication(config.current.name, onResult, signal); +} diff --git a/packages/cli/src/profiles/commands.ts b/packages/cli/src/profiles/commands.ts index 45ee4187b..85cf32c4a 100644 --- a/packages/cli/src/profiles/commands.ts +++ b/packages/cli/src/profiles/commands.ts @@ -3,7 +3,8 @@ import colors from 'ansi-colors'; import enquirer from "enquirer"; import jwt from 'jsonwebtoken'; import { AVAILABLE_REGIONS, DEFAULT_REGION, Region, config, getConfigUrl, getServerUrls, shouldRefreshProfileToken } from "./index.js"; -import { deleteAuthBundle, getAccessTokenExpiry, readProfileAccessToken, writeAuthBundle } from "./keyring.js"; +import { deleteAuthBundle, getAccessTokenExpiry, writeAuthBundle } from "./keyring.js"; +import { ensureProfileAccessToken, refreshCurrentProfileAuthentication, refreshProfileAuthentication } from './auth.js'; import { ConfigResult } from './server/index.js'; const { prompt } = enquirer; @@ -79,18 +80,19 @@ export async function showActiveAuthToken() { console.log('No profiles are defined. Run `vertesia profiles create` to add a new profile.'); return; } else if (config.current) { - const token = readProfileAccessToken(config.current); + let token: string | undefined; + try { + token = await ensureProfileAccessToken(config.current); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + console.error('Run `vertesia auth refresh` to authenticate again.'); + process.exit(1); + } if (!token) { console.log('No auth token is stored for the current profile. Run `vertesia auth refresh` to authenticate again.'); return; } - const expiresAt = getAccessTokenExpiry(token); - if (expiresAt && expiresAt < Date.now()) { - console.log("Authentication token expired. Create a new one "); - await _doRefreshToken(config.current.name); - } else { - console.log(token); - } + console.log(token); } else { console.log('No profile is selected. Run `vertesia auth refresh` to refresh the token'); } @@ -242,12 +244,15 @@ export async function updateProfile(name?: string, onResult?: OnResultCallback, await config.updateProfile(name).start(onResult, signal); } -export function updateCurrentProfile(onResult?: OnResultCallback, signal?: AbortSignal): Promise { - if (!config.current) { - console.log("No profile is selected. Run `vertesia profiles use ` to select a profile"); - process.exit(1); +export async function refreshProfile(name?: string, onResult?: OnResultCallback, signal?: AbortSignal): Promise { + if (!name) { + name = await selectProfile("Select the profile to refresh"); } - return config.updateProfile(config.current.name).start(onResult, signal); + return refreshProfileAuthentication(name, onResult, signal); +} + +export function updateCurrentProfile(onResult?: OnResultCallback, signal?: AbortSignal): Promise { + return refreshCurrentProfileAuthentication(onResult, signal).then(() => undefined); } @@ -359,7 +364,7 @@ async function _doRefreshToken(profileName: string, onResult?: OnResultCallback) initial: true, }); if (r.refresh) { - await config.updateProfile(profileName).start(onResult, abortController.signal); + await refreshProfileAuthentication(profileName, onResult, abortController.signal); } } finally { process.off('SIGINT', handleSignal); diff --git a/packages/cli/src/profiles/index.ts b/packages/cli/src/profiles/index.ts index 689c3d02e..f9bc21c03 100644 --- a/packages/cli/src/profiles/index.ts +++ b/packages/cli/src/profiles/index.ts @@ -4,7 +4,8 @@ import os from "node:os"; import { join } from "path"; import { readJsonFile, writeJsonFile } from "../utils/stdio.js"; import { ConfigPayload, ConfigResult, startConfigSession } from "./server/index.js"; -import { OnResultCallback } from "./commands.js"; +import type { OnResultCallback } from "./commands.js"; +import { canUseOAuthProfile, startOAuthSession } from "./oauth.js"; import { deleteAuthBundle, getAccessTokenExpiry, hasStoredAccessToken, isKeyringAvailable, readAuthBundle, readProfileAccessToken, writeAuthBundle } from "./keyring.js"; export function getConfigFile(path?: string) { @@ -133,11 +134,8 @@ export class ConfigureProfile { } } - async applyConfigResult(result: ConfigResult | undefined) { + async persistConfigResult(result: ConfigResult | undefined) { if (!result) { - // Handle cancellation or no result - console.log('\nAuthentication canceled or failed.'); - process.exit(1); return; } const oldName = this.data.name!; @@ -153,6 +151,8 @@ export class ConfigureProfile { accessTokenExpiresAt: readResultAccessTokenExpiry(result), refreshToken: result.refresh_token || previousBundle?.refreshToken, refreshTokenExpiresAt: result.refresh_token_expires_at || previousBundle?.refreshTokenExpiresAt, + oauthClientId: result.oauth_client_id || previousBundle?.oauthClientId, + oauthResource: result.oauth_resource || previousBundle?.oauthResource, }); if (oldName && oldName !== result.profile) { deleteAuthBundle(oldName); @@ -167,21 +167,45 @@ export class ConfigureProfile { await this.onResultCallback(result); this.onResultCallback = undefined; } - // force exit to close last prompt - console.log('\n'); - console.log('Authentication completed.'); - process.exit(0); } - async start(onResult?: OnResultCallback, signal?: AbortSignal) { - this.onResultCallback = onResult; + async applyConfigResult( + result: ConfigResult | undefined, + options: { logCompletion?: boolean; exitOnComplete?: boolean } = {}, + ) { + if (!result) { + console.log('\nAuthentication canceled or failed.'); + process.exit(1); + return; + } + await this.persistConfigResult(result); + if (options.logCompletion) { + console.log('\n'); + console.log('Authentication completed.'); + } + if (options.exitOnComplete) { + process.exit(0); + } + } + + private async startLegacySession(signal?: AbortSignal) { await startConfigSession( - this.data.config_url!, - this.getConfigPayload(), - this.applyConfigResult.bind(this), + this.data.config_url!, + this.getConfigPayload(), + (result) => this.applyConfigResult(result, { logCompletion: true, exitOnComplete: true }), signal ); } + + async start(onResult?: OnResultCallback, signal?: AbortSignal) { + this.onResultCallback = onResult; + if (canUseOAuthProfile(this.data)) { + const result = await startOAuthSession(this.data as Pick & Partial>, signal); + await this.applyConfigResult(result, { logCompletion: true }); + return; + } + await this.startLegacySession(signal); + } } export class Config { @@ -254,7 +278,7 @@ export class Config { createProfile(name: string, target: ConfigUrlRef, region: Region = DEFAULT_REGION) { const config_url = getConfigUrl(target, region); - return new ConfigureProfile(this, { name, config_url, region }, true); + return new ConfigureProfile(this, { name, config_url, region, ...readKnownServerUrls(target, region) }, true); } updateProfile(name: string) { @@ -267,12 +291,13 @@ export class Config { createOrUpdateProfile(name: string, target?: ConfigUrlRef): ConfigureProfile { const config_url = target && getConfigUrl(target); + const knownServerUrls = target ? readKnownServerUrls(target) : {}; const data = this.getProfile(name); if (config_url) { // create a new profile on config_url if (data) { throw new ProfileAlreadyExistsError(`Profile ${name} already exists.`); } else { - return new ConfigureProfile(this, { name, config_url }, true); + return new ConfigureProfile(this, { name, config_url, ...knownServerUrls }, true); } } else { // update an existing profile if (data) { @@ -395,3 +420,11 @@ function readResultAccessTokenExpiry(result: ConfigResult): number | undefined { } return readInlineTokenExpiry(result.token); } + +function readKnownServerUrls(target: ConfigUrlRef, region: Region = DEFAULT_REGION): Partial> { + try { + return getServerUrls(target, region); + } catch { + return {}; + } +} diff --git a/packages/cli/src/profiles/keyring.ts b/packages/cli/src/profiles/keyring.ts index b40204cca..c5edef693 100644 --- a/packages/cli/src/profiles/keyring.ts +++ b/packages/cli/src/profiles/keyring.ts @@ -22,6 +22,8 @@ export interface StoredAuthBundle { accessTokenExpiresAt?: number; refreshToken?: string; refreshTokenExpiresAt?: number; + oauthClientId?: string; + oauthResource?: string; } type WritableAuthBundle = Omit; @@ -91,6 +93,8 @@ export function writeAuthBundle(profileName: string, bundle: WritableAuthBundle) accessTokenExpiresAt: bundle.accessTokenExpiresAt, refreshToken: bundle.refreshToken, refreshTokenExpiresAt: bundle.refreshTokenExpiresAt, + oauthClientId: bundle.oauthClientId, + oauthResource: bundle.oauthResource, }; getEntry(profileName).setPassword(JSON.stringify(payload)); } diff --git a/packages/cli/src/profiles/oauth.ts b/packages/cli/src/profiles/oauth.ts new file mode 100644 index 000000000..f3a4b05c9 --- /dev/null +++ b/packages/cli/src/profiles/oauth.ts @@ -0,0 +1,434 @@ +import crypto from 'node:crypto'; +import jwt from 'jsonwebtoken'; +import open from 'open'; +import { startServer } from './server/server.js'; +import type { OAuthAuthorizationServerMetadata, OAuthTokenResponse } from '@vertesia/common'; +import type { Profile } from './index.js'; +import type { StoredAuthBundle } from './keyring.js'; +import type { ConfigResult } from './server/index.js'; + +const OAUTH_AUTHORIZATION_SERVER_PATH = '/.well-known/oauth-authorization-server'; +const OAUTH_CLIENT_METADATA_PATH = '/.well-known/oauth-client/vertesia-cli'; +const OAUTH_CALLBACK_PATH = '/oauth/callback'; +const OAUTH_LOOPBACK_HOST = '127.0.0.1'; +const DEFAULT_OAUTH_SCOPE = 'openid profile'; + +type OAuthProfile = Pick & Partial>; + +interface TokenRefs { + account?: string; + project?: string; + audience?: string; +} + +interface PkcePair { + verifier: string; + challenge: string; +} + +export function canUseOAuthProfile(profile: Partial>): boolean { + if (!profile.studio_server_url) { + return false; + } + try { + const url = new URL(profile.studio_server_url); + const isLoopbackHost = url.hostname === 'localhost' || url.hostname === '127.0.0.1'; + const isLocalDev = process.env.IS_LOCAL_DEV === 'true'; + if (url.protocol === 'https:') { + return !isLoopbackHost || isLocalDev; + } + return isLocalDev && url.protocol === 'http:' && isLoopbackHost; + } catch { + return false; + } +} + +export function getOAuthClientId(profile: Pick): string { + return new URL(OAUTH_CLIENT_METADATA_PATH, withTrailingSlash(profile.studio_server_url)).toString(); +} + +export function getOAuthResource(metadata: Pick): string { + return new URL(metadata.issuer).toString(); +} + +export async function startOAuthSession(profile: OAuthProfile, signal?: AbortSignal): Promise { + assertOAuthProfile(profile); + + const metadata = await fetchAuthorizationServerMetadata(profile.studio_server_url); + const clientId = getOAuthClientId(profile); + const resource = getOAuthResource(metadata); + const scope = DEFAULT_OAUTH_SCOPE; + const pkce = createPkcePair(); + const state = crypto.randomUUID(); + + const callback = await createAuthorizationCallback(state, signal); + const redirectUri = callback.redirectUri; + const authorizeUrl = buildAuthorizeUrl(metadata, { + clientId, + redirectUri, + resource, + scope, + state, + challenge: pkce.challenge, + projectId: profile.project, + }); + + console.log('Opening browser to', authorizeUrl); + open(authorizeUrl).catch((error) => { + console.error('Unable to open browser:', error instanceof Error ? error.message : String(error)); + }); + + try { + const code = await callback.waitForCode(); + const response = await exchangeAuthorizationCode(metadata, clientId, code, pkce.verifier, redirectUri, resource); + return buildConfigResult(profile, response, clientId, resource); + } finally { + callback.close(); + } +} + +export async function refreshOAuthSession( + profile: OAuthProfile, + refreshToken: string, + bundle?: StoredAuthBundle, +): Promise { + assertOAuthProfile(profile); + + const metadata = await fetchAuthorizationServerMetadata(profile.studio_server_url); + const clientId = bundle?.oauthClientId || getOAuthClientId(profile); + const resource = bundle?.oauthResource || readTokenRefs(bundle?.accessToken).audience || getOAuthResource(metadata); + const response = await exchangeRefreshToken(metadata, clientId, refreshToken, resource); + return buildConfigResult(profile, response, clientId, resource); +} + +function assertOAuthProfile(profile: OAuthProfile): asserts profile is OAuthProfile & Required> { + if (!profile.name) { + throw new Error('Profile name is required for OAuth authentication.'); + } + if (!profile.studio_server_url || !profile.zeno_server_url) { + throw new Error('Studio and Zeno server URLs are required for OAuth authentication.'); + } + if (!canUseOAuthProfile(profile)) { + throw new Error(`OAuth login is not supported for studio endpoint "${profile.studio_server_url}".`); + } +} + +function withTrailingSlash(url: string): string { + return url.endsWith('/') ? url : `${url}/`; +} + +async function fetchAuthorizationServerMetadata(studioServerUrl: string): Promise { + const response = await fetch(new URL(OAUTH_AUTHORIZATION_SERVER_PATH, withTrailingSlash(studioServerUrl)).toString(), { + headers: { + Accept: 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`Failed to load OAuth authorization metadata from ${studioServerUrl} (${response.status} ${response.statusText}).`); + } + + const metadata = await response.json() as Partial; + if (!metadata.authorization_endpoint || !metadata.token_endpoint || !metadata.issuer) { + throw new Error(`Invalid OAuth authorization metadata returned by ${studioServerUrl}.`); + } + return metadata as OAuthAuthorizationServerMetadata; +} + +function createPkcePair(): PkcePair { + const verifier = crypto.randomBytes(32).toString('base64url'); + const challenge = crypto.createHash('sha256').update(verifier).digest('base64url'); + return { verifier, challenge }; +} + +async function createAuthorizationCallback(expectedState: string, signal?: AbortSignal) { + let settled = false; + let rejectAuthorization: (error: Error) => void = () => {}; + let resolveAuthorization: (code: string) => void = () => {}; + + const waitForCode = new Promise((resolve, reject) => { + resolveAuthorization = resolve; + rejectAuthorization = reject; + }); + + const server = await startServer((req, res) => { + const requestUrl = new URL(req.url || '/', `http://${req.headers.host || OAUTH_LOOPBACK_HOST}`); + + if (req.method !== 'GET' || requestUrl.pathname !== OAUTH_CALLBACK_PATH) { + res.statusCode = 404; + res.end(); + return; + } + if (settled) { + res.statusCode = 409; + res.end('Authentication already completed.'); + return; + } + + const state = requestUrl.searchParams.get('state'); + const code = requestUrl.searchParams.get('code'); + const error = requestUrl.searchParams.get('error'); + const errorDescription = requestUrl.searchParams.get('error_description'); + + if (error) { + settled = true; + res.statusCode = 400; + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.end(errorDescription || error); + rejectAuthorization(new Error(errorDescription || error)); + closeServer(); + return; + } + + if (!state || state !== expectedState) { + settled = true; + res.statusCode = 400; + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.end('State mismatch.'); + rejectAuthorization(new Error('OAuth state mismatch.')); + closeServer(); + return; + } + + if (!code) { + settled = true; + res.statusCode = 400; + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.end('No authorization code was returned.'); + rejectAuthorization(new Error('OAuth authorization code missing from callback.')); + closeServer(); + return; + } + + settled = true; + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.end('Authentication complete. You can close this window.'); + resolveAuthorization(code); + closeServer(); + }); + const onAbort = () => { + if (settled) { + return; + } + settled = true; + closeServer(); + rejectAuthorization(new Error('Authentication aborted.')); + }; + + const closeServer = () => { + if (server.listening) { + server.close(); + } + if (signal) { + signal.removeEventListener('abort', onAbort); + } + }; + + if (signal?.aborted) { + onAbort(); + } + + if (signal) { + signal.addEventListener('abort', onAbort, { once: true }); + } + + const address = server.address(); + if (!address || typeof address === 'string') { + settled = true; + closeServer(); + throw new Error('Unable to determine local OAuth callback port.'); + } + const redirectUri = `http://${OAUTH_LOOPBACK_HOST}:${address.port}${OAUTH_CALLBACK_PATH}`; + + return { + redirectUri, + waitForCode() { + return waitForCode; + }, + close() { + if (!settled) { + settled = true; + rejectAuthorization(new Error('Authentication interrupted.')); + } + closeServer(); + }, + }; +} + +function buildAuthorizeUrl( + metadata: OAuthAuthorizationServerMetadata, + input: { + clientId: string; + redirectUri: string; + resource: string; + scope: string; + state: string; + challenge: string; + projectId?: string; + }, +): string { + const url = new URL(metadata.authorization_endpoint); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('client_id', input.clientId); + url.searchParams.set('redirect_uri', input.redirectUri); + url.searchParams.set('resource', input.resource); + url.searchParams.set('scope', input.scope); + url.searchParams.set('state', input.state); + url.searchParams.set('code_challenge', input.challenge); + url.searchParams.set('code_challenge_method', 'S256'); + if (input.projectId) { + url.searchParams.set('project_id', input.projectId); + } + return url.toString(); +} + +async function exchangeAuthorizationCode( + metadata: OAuthAuthorizationServerMetadata, + clientId: string, + code: string, + verifier: string, + redirectUri: string, + resource: string, +): Promise { + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + client_id: clientId, + resource, + code_verifier: verifier, + }); + return exchangeToken(metadata.token_endpoint, body); +} + +async function exchangeRefreshToken( + metadata: OAuthAuthorizationServerMetadata, + clientId: string, + refreshToken: string, + resource: string, +): Promise { + const body = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: clientId, + resource, + }); + return exchangeToken(metadata.token_endpoint, body); +} + +async function exchangeToken(endpoint: string, body: URLSearchParams): Promise { + const response = await fetch(endpoint, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: body.toString(), + }); + + if (!response.ok) { + throw new Error(`OAuth token exchange failed (${response.status}): ${await readErrorMessage(response)}`); + } + + const payload = await response.json() as Partial; + if (!payload.access_token || !payload.token_type || typeof payload.expires_in !== 'number') { + throw new Error('OAuth token endpoint returned an invalid response.'); + } + return payload as OAuthTokenResponse; +} + +async function readErrorMessage(response: Response): Promise { + const text = await response.text(); + if (!text) { + return response.statusText || 'Unknown error'; + } + try { + const parsed = JSON.parse(text) as Record; + if (typeof parsed.error_description === 'string') { + return parsed.error_description; + } + if (typeof parsed.error === 'string') { + return parsed.error; + } + } catch { + // Ignore non-JSON error responses. + } + return text; +} + +function buildConfigResult( + profile: OAuthProfile, + response: OAuthTokenResponse, + oauthClientId: string, + oauthResource: string, +): ConfigResult { + const refs = readTokenRefs(response.access_token); + const account = refs.account || profile.account; + const project = refs.project || profile.project; + + if (!profile.name || !profile.studio_server_url || !profile.zeno_server_url) { + throw new Error('Profile metadata is incomplete after OAuth authentication.'); + } + if (!account || !project) { + throw new Error('OAuth access token did not contain an account or project reference.'); + } + + return { + profile: profile.name, + account, + project, + studio_server_url: profile.studio_server_url, + zeno_server_url: profile.zeno_server_url, + token: response.access_token, + refresh_token: response.refresh_token, + expires_in: response.expires_in, + oauth_client_id: oauthClientId, + oauth_resource: oauthResource, + }; +} + +function readTokenRefs(token: string | undefined): TokenRefs { + if (!token) { + return {}; + } + const decoded = jwt.decode(token, { json: true }); + if (!decoded || typeof decoded !== 'object') { + return {}; + } + const audience = readAudience(decoded); + return { + account: readRefId(decoded, 'account') || readStringField(decoded, 'account_id'), + project: readRefId(decoded, 'project') || readStringField(decoded, 'project_id'), + audience, + }; +} + +function readAudience(decoded: object): string | undefined { + const audience = Reflect.get(decoded, 'aud'); + if (typeof audience === 'string') { + return audience; + } + if (Array.isArray(audience)) { + const first = audience.find((value) => typeof value === 'string'); + return typeof first === 'string' ? first : undefined; + } + return undefined; +} + +function readRefId(value: object, key: string): string | undefined { + const field = Reflect.get(value, key); + if (typeof field === 'string') { + return field; + } + if (!field || typeof field !== 'object' || Array.isArray(field)) { + return undefined; + } + const id = Reflect.get(field, 'id'); + return typeof id === 'string' ? id : undefined; +} + +function readStringField(value: object, key: string): string | undefined { + const field = Reflect.get(value, key); + return typeof field === 'string' ? field : undefined; +} diff --git a/packages/cli/src/profiles/server/index.ts b/packages/cli/src/profiles/server/index.ts index 69ff6352e..e60c61744 100644 --- a/packages/cli/src/profiles/server/index.ts +++ b/packages/cli/src/profiles/server/index.ts @@ -21,6 +21,8 @@ export interface ConfigResult extends Required { expires_in?: number; access_token_expires_at?: number; refresh_token_expires_at?: number; + oauth_client_id?: string; + oauth_resource?: string; } diff --git a/packages/cli/src/worker/connect.ts b/packages/cli/src/worker/connect.ts index 4e7a8301d..593baaa95 100644 --- a/packages/cli/src/worker/connect.ts +++ b/packages/cli/src/worker/connect.ts @@ -1,5 +1,5 @@ import enquirer from "enquirer"; -import { createProfile, updateProfile } from "../profiles/commands.js"; +import { createProfile, refreshProfile } from "../profiles/commands.js"; import { config, shouldRefreshProfileToken } from "../profiles/index.js"; import { ConfigResult } from "../profiles/server/index.js"; import { WorkerProject } from "./project.js"; @@ -40,7 +40,7 @@ export async function connectToProject(options: ConnectOptions) { } if (allowInteraction && shouldRefreshProfileToken(profile, 10)) { console.log("Refreshing auth token for profile:", profileName); - await updateProfile(profileName, onAuthenticationDone); + await refreshProfile(profileName, onAuthenticationDone); } else { await updateNpmrc(project, profileName); } @@ -70,4 +70,4 @@ async function askProfileName() { } } return undefined; // create new profile -} \ No newline at end of file +} diff --git a/packages/cli/src/worker/refresh.ts b/packages/cli/src/worker/refresh.ts index 5facb9e15..163b5a3d0 100644 --- a/packages/cli/src/worker/refresh.ts +++ b/packages/cli/src/worker/refresh.ts @@ -1,4 +1,4 @@ -import { updateProfile } from "../profiles/commands.js"; +import { refreshProfile } from "../profiles/commands.js"; import { config, Profile, shouldRefreshProfileToken } from "../profiles/index.js"; import { ConfigResult } from "../profiles/server/index.js"; import { WorkerProject } from "./project.js"; @@ -51,6 +51,6 @@ export function tryRefreshToken(profile: Profile): Promise { it("rejects malformed process definition shape for editor diagnostics", () => { const validate = new Ajv({ allErrors: true, strict: false }).compile(ProcessDefinitionBodyJsonSchema); const invalidDefinition = { + format_version: PROCESS_DEFINITION_FORMAT_VERSION, process: "invoice_review", initial: "fanout", context: { @@ -69,7 +83,7 @@ describe("process definition JSON schema", () => { }, nodes: { fanout: { - type: "parallel", + type: "foreach", collect: { into: "results", include: ["bogus"], diff --git a/packages/common/src/store/process-schema.ts b/packages/common/src/store/process-schema.ts index 94b707a94..c08ab7ae9 100644 --- a/packages/common/src/store/process-schema.ts +++ b/packages/common/src/store/process-schema.ts @@ -1,12 +1,17 @@ import type { JSONSchemaType } from "../json-schema.js"; -export const PROCESS_DEFINITION_JSON_SCHEMA_ID = "https://schemas.vertesia.com/process-definition.schema.json"; +export const PROCESS_DEFINITION_JSON_SCHEMA_ID = "https://schemas.vertesia.com/process-definition.v1.schema.json"; export const ProcessDefinitionBodyJsonSchema = { $id: PROCESS_DEFINITION_JSON_SCHEMA_ID, type: "object", description: "A process definition describes a state-machine workflow executed by the Process Engine.", properties: { + format_version: { + type: "integer", + const: 1, + description: "Native process definition format version.", + }, process: { type: "string", minLength: 1, @@ -39,10 +44,20 @@ export const ProcessDefinitionBodyJsonSchema = { $ref: "#/$defs/nodeDefinition", }, }, + metadata: { + $ref: "#/$defs/metadataDefinition", + }, }, - required: ["process", "initial", "context", "nodes"], + required: ["format_version", "process", "initial", "context", "nodes"], additionalProperties: false, $defs: { + metadataDefinition: { + type: "object", + nullable: true, + description: "Execution-irrelevant metadata such as editor layout, provenance, and audit hints.", + required: [], + additionalProperties: true, + }, processContextDefinition: { type: "object", properties: { @@ -72,7 +87,7 @@ export const ProcessDefinitionBodyJsonSchema = { properties: { type: { type: "string", - enum: ["tool", "interaction", "agent", "process", "human_task", "parallel", "condition", "final"], + enum: ["tool", "interaction", "agent", "process", "human_task", "foreach", "branch", "condition", "final"], description: "Node executor type.", }, tool: { @@ -181,30 +196,30 @@ export const ProcessDefinitionBodyJsonSchema = { foreach: { type: "string", nullable: true, - description: "Context path to an array for parallel nodes.", + description: "Context path to an array for foreach nodes.", }, as: { type: "string", nullable: true, - description: "Variable name for the current parallel item. Defaults to item.", + description: "Variable name for the current foreach item. Defaults to item.", }, item_id: { type: "string", nullable: true, - description: "Template for a stable item id in parallel collection output.", + description: "Template for a stable item id in foreach collection output.", }, node: { $ref: "#/$defs/nodeDefinition", - description: "Child node executed for each parallel item.", + description: "Child node executed for each foreach item or branch child definition.", }, max_concurrency: { type: "integer", minimum: 1, nullable: true, - description: "Maximum parallel child executions.", + description: "Maximum concurrent child executions for foreach nodes.", }, collect: { - description: "Where and how to collect parallel results.", + description: "Where and how to collect foreach or branch results.", oneOf: [ { type: "string", minLength: 1 }, { $ref: "#/$defs/parallelCollectDefinition" }, @@ -214,13 +229,27 @@ export const ProcessDefinitionBodyJsonSchema = { type: "string", enum: ["fail_fast", "collect_errors"], nullable: true, - description: "Parallel failure policy.", + description: "Foreach or branch failure policy.", + }, + join: { + type: "string", + enum: ["all"], + nullable: true, + description: "Branch join policy.", }, branches: { type: "array", nullable: true, - description: "Condition-node branches. Semantic validation verifies targets exist.", - items: { $ref: "#/$defs/branchDefinition" }, + description: "Condition or branch-node branches. Semantic validation verifies the type-specific shape.", + items: { + oneOf: [ + { $ref: "#/$defs/branchDefinition" }, + { $ref: "#/$defs/branchNodeBranchDefinition" }, + ], + }, + }, + metadata: { + $ref: "#/$defs/metadataDefinition", }, }, required: ["type"], @@ -249,6 +278,9 @@ export const ProcessDefinitionBodyJsonSchema = { nullable: true, description: "Display label.", }, + metadata: { + $ref: "#/$defs/metadataDefinition", + }, }, required: ["to"], additionalProperties: false, @@ -270,10 +302,42 @@ export const ProcessDefinitionBodyJsonSchema = { nullable: true, description: "Fallback branch used when no condition matches.", }, + metadata: { + $ref: "#/$defs/metadataDefinition", + }, }, required: ["to"], additionalProperties: false, }, + branchNodeBranchDefinition: { + type: "object", + properties: { + id: { + type: "string", + minLength: 1, + description: "Stable branch id.", + }, + title: { + type: "string", + nullable: true, + description: "Optional branch title.", + }, + description: { + type: "string", + nullable: true, + description: "Optional branch description.", + }, + node: { + $ref: "#/$defs/nodeDefinition", + description: "Child node executed for this branch.", + }, + metadata: { + $ref: "#/$defs/metadataDefinition", + }, + }, + required: ["id", "node"], + additionalProperties: false, + }, humanTaskDefinition: { type: "object", properties: { @@ -380,6 +444,8 @@ export const ProcessDefinitionBodyJsonSchema = { "index", "item", "item_id", + "branch_id", + "branch_title", "output", "context_update", "error", diff --git a/packages/common/src/store/process-validation.test.ts b/packages/common/src/store/process-validation.test.ts index 471423589..e8abfbcbb 100644 --- a/packages/common/src/store/process-validation.test.ts +++ b/packages/common/src/store/process-validation.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { ProcessDefinitionBody } from "./process.js"; +import { PROCESS_DEFINITION_FORMAT_VERSION, type ProcessDefinitionBody } from "./process.js"; import { MAX_PROCESS_GUARD_DEPTH, MAX_PROCESS_GUARD_NODES, @@ -9,8 +9,16 @@ import { function validDefinition(): ProcessDefinitionBody { return { + format_version: PROCESS_DEFINITION_FORMAT_VERSION, process: "approval", initial: "review", + metadata: { + provenance: { + bpmn: { + process_id: "ApprovalProcess", + }, + }, + }, context: { schema: { type: "object", @@ -29,7 +37,7 @@ function validDefinition(): ProcessDefinitionBody { title: "Review", fields: [{ name: "approved", type: "boolean", required: true }], }, - transitions: [{ to: "approved", trigger: "user" }], + transitions: [{ to: "approved", trigger: "user", metadata: { edge_kind: "default" } }], }, approved: { type: "final", @@ -64,7 +72,7 @@ describe("process definition validation", () => { ); }); - it("rejects unsupported parallel child node types", () => { + it("rejects unsupported foreach child node types", () => { const definition = validDefinition(); definition.initial = "fanout"; definition.context.schema = { @@ -77,7 +85,7 @@ describe("process definition validation", () => { definition.context.initial = { items: [] }; definition.nodes = { fanout: { - type: "parallel", + type: "foreach", foreach: "items", node: { type: "human_task", @@ -94,11 +102,11 @@ describe("process definition validation", () => { }; expect(() => validateProcessDefinitionBody(definition)).toThrow( - 'parallel node "fanout" has unsupported child node type "human_task"', + 'foreach node "fanout" has unsupported child node type "human_task"', ); }); - it("accepts process nodes as standalone nodes and parallel children", () => { + it("accepts process nodes as standalone nodes and foreach children", () => { const child = validDefinition(); child.process = "child_approval"; @@ -115,7 +123,7 @@ describe("process definition validation", () => { definition.context.initial = { items: [] }; definition.nodes = { fanout: { - type: "parallel", + type: "foreach", foreach: "items", as: "item", item_id: "{{item.id}}", @@ -140,6 +148,41 @@ describe("process definition validation", () => { expect(() => validateProcessDefinitionBody(definition)).not.toThrow(); }); + it("rejects fanout child nodes that define their own transitions", () => { + const child = validDefinition(); + child.process = "child_approval"; + + const definition = validDefinition(); + definition.initial = "fanout"; + definition.context.schema = { + type: "object", + properties: { + items: { type: "array", items: { type: "object" } }, + }, + additionalProperties: true, + }; + definition.context.initial = { items: [] }; + definition.nodes = { + fanout: { + type: "foreach", + foreach: "items", + node: { + type: "process", + process_definition: child, + transitions: [{ to: "approved" }], + }, + transitions: [{ to: "approved" }], + }, + approved: { + type: "final", + }, + }; + + expect(() => validateProcessDefinitionBody(definition)).toThrow( + 'foreach node "fanout" child node must not define transitions', + ); + }); + it("rejects process nodes without a referenced or inline process", () => { const definition = validDefinition(); definition.nodes.review = { @@ -152,7 +195,7 @@ describe("process definition validation", () => { ); }); - it("rejects invalid parallel fanout controls", () => { + it("rejects invalid foreach fanout controls", () => { const definition = validDefinition(); definition.initial = "fanout"; definition.context.schema = { @@ -165,7 +208,7 @@ describe("process definition validation", () => { definition.context.initial = { items: [] }; definition.nodes = { fanout: { - type: "parallel", + type: "foreach", foreach: "items", max_concurrency: 0, collect: { @@ -186,13 +229,14 @@ describe("process definition validation", () => { const result = getProcessDefinitionValidationResult(definition); expect(result.valid).toBe(false); - expect(result.errors).toContain('parallel node "fanout" max_concurrency must be a positive integer'); - expect(result.errors).toContain('parallel node "fanout" collect.into is required'); - expect(result.errors).toContain('parallel node "fanout" collect.include has invalid field "bogus"'); + expect(result.errors).toContain('foreach node "fanout" max_concurrency must be a positive integer'); + expect(result.errors).toContain('foreach node "fanout" collect.into is required'); + expect(result.errors).toContain('foreach node "fanout" collect.include has invalid field "bogus"'); }); it("reports all structural errors without importing runtime schema validators", () => { const definition = { + format_version: PROCESS_DEFINITION_FORMAT_VERSION, process: "", initial: "missing", context: { diff --git a/packages/common/src/store/process-validation.ts b/packages/common/src/store/process-validation.ts index 76bc04bd4..c9d054557 100644 --- a/packages/common/src/store/process-validation.ts +++ b/packages/common/src/store/process-validation.ts @@ -1,4 +1,10 @@ -import type { NodeDefinition, ProcessDefinitionBody } from "./process.js"; +import { + PROCESS_DEFINITION_FORMAT_VERSION, + type BranchDefinition, + type BranchNodeBranchDefinition, + type NodeDefinition, + type ProcessDefinitionBody, +} from "./process.js"; export interface ProcessDefinitionValidationResult { valid: boolean; @@ -26,6 +32,9 @@ export function getProcessDefinitionValidationResult(definition: ProcessDefiniti if (!definition.process) { errors.push("process is missing"); } + if (definition.format_version !== PROCESS_DEFINITION_FORMAT_VERSION) { + errors.push(`format_version must be ${PROCESS_DEFINITION_FORMAT_VERSION}`); + } if (!definition.initial) { errors.push("initial node is missing"); } @@ -89,19 +98,57 @@ function validateNodeDefinition( } } } - if (node.type === "parallel") { + if (node.type === "foreach") { + if (!node.foreach) { + errors.push(`foreach node "${nodeId}" is missing foreach`); + } + if (!node.node) { + errors.push(`foreach node "${nodeId}" is missing node`); + } if (node.max_concurrency !== undefined && (!Number.isInteger(node.max_concurrency) || node.max_concurrency < 1)) { - errors.push(`parallel node "${nodeId}" max_concurrency must be a positive integer`); + errors.push(`foreach node "${nodeId}" max_concurrency must be a positive integer`); } if (node.item_id !== undefined && typeof node.item_id !== "string") { - errors.push(`parallel node "${nodeId}" item_id must be a string`); + errors.push(`foreach node "${nodeId}" item_id must be a string`); } - validateParallelCollectDefinition(nodeId, node.collect, errors); + validateCollectDefinition("foreach", nodeId, node.collect, errors); if (node.node) { - if (!isParallelChildNodeType(node.node.type)) { - errors.push(`parallel node "${nodeId}" has unsupported child node type "${String(node.node.type)}"`); + validateFanoutChildNodeDefinition(definition, { + ownerLabel: `foreach node "${nodeId}"`, + childPath: `${nodeId}.node`, + childLabel: `foreach node "${nodeId}" child node`, + child: node.node, + }, errors); + } + } + if (node.type === "branch") { + if (!Array.isArray(node.branches) || node.branches.length === 0) { + errors.push(`branch node "${nodeId}" must define at least one branch`); + } + if (node.join !== undefined && node.join !== "all") { + errors.push(`branch node "${nodeId}" join must be "all"`); + } + validateCollectDefinition("branch", nodeId, node.collect, errors); + const branches = getBranchNodeBranches(node); + const seenIds = new Set(); + for (const [index, branch] of branches.entries()) { + if (!branch.id) { + errors.push(`branch node "${nodeId}" branch at index ${index} is missing id`); + } else if (seenIds.has(branch.id)) { + errors.push(`branch node "${nodeId}" has duplicate branch id "${branch.id}"`); + } else { + seenIds.add(branch.id); } - validateNodeDefinition(definition, `${nodeId}.node`, node.node, errors); + if (!branch.node) { + errors.push(`branch node "${nodeId}" branch "${branch.id || index}" is missing node`); + continue; + } + validateFanoutChildNodeDefinition(definition, { + ownerLabel: `branch node "${nodeId}" branch "${branch.id || index}"`, + childPath: `${nodeId}.branches.${index}.node`, + childLabel: `branch node "${nodeId}" branch "${branch.id || index}" child node`, + child: branch.node, + }, errors); } } if (node.failure_policy && !isParallelFailurePolicy(node.failure_policy)) { @@ -119,7 +166,7 @@ function validateNodeDefinition( } } - for (const branch of node.branches ?? []) { + for (const branch of getConditionBranches(node)) { if (!definition.nodes[branch.to]) { errors.push(`node "${nodeId}" has branch to "${branch.to}" which does not exist`); } @@ -135,7 +182,8 @@ function isProcessNodeType(value: string): boolean { || value === "agent" || value === "process" || value === "human_task" - || value === "parallel" + || value === "foreach" + || value === "branch" || value === "condition" || value === "final"; } @@ -152,42 +200,81 @@ function isParallelFailurePolicy(value: string): boolean { return value === "fail_fast" || value === "collect_errors"; } -function isParallelChildNodeType(value: string): boolean { +function isFanoutChildNodeType(value: string): boolean { return value === "tool" || value === "interaction" || value === "agent" - || value === "process" - || value === "condition"; + || value === "process"; +} + +function validateFanoutChildNodeDefinition( + definition: ProcessDefinitionBody, + input: { + ownerLabel: string; + childPath: string; + childLabel: string; + child: NodeDefinition; + }, + errors: string[], +) { + const { ownerLabel, childPath, childLabel, child } = input; + if (!isFanoutChildNodeType(child.type)) { + errors.push(`${ownerLabel} has unsupported child node type "${String(child.type)}"`); + return; + } + + if ((child.transitions ?? []).length > 0) { + errors.push(`${childLabel} must not define transitions`); + } + if ((child.branches ?? []).length > 0) { + errors.push(`${childLabel} must not define branches`); + } + + validateNodeDefinition( + definition, + childPath, + { + ...child, + transitions: undefined, + branches: undefined, + }, + errors, + ); } -function validateParallelCollectDefinition(nodeId: string, collect: NodeDefinition["collect"], errors: string[]) { +function validateCollectDefinition( + nodeKind: "foreach" | "branch", + nodeId: string, + collect: NodeDefinition["collect"], + errors: string[], +) { if (collect === undefined) { return; } if (typeof collect === "string") { if (!collect) { - errors.push(`parallel node "${nodeId}" collect must not be empty`); + errors.push(`${nodeKind} node "${nodeId}" collect must not be empty`); } return; } if (!isRecord(collect)) { - errors.push(`parallel node "${nodeId}" collect must be a string or object`); + errors.push(`${nodeKind} node "${nodeId}" collect must be a string or object`); return; } if (typeof collect.into !== "string" || !collect.into) { - errors.push(`parallel node "${nodeId}" collect.into is required`); + errors.push(`${nodeKind} node "${nodeId}" collect.into is required`); } if (collect.mode !== undefined && collect.mode !== "array") { - errors.push(`parallel node "${nodeId}" collect.mode must be "array"`); + errors.push(`${nodeKind} node "${nodeId}" collect.mode must be "array"`); } if (collect.include !== undefined) { if (!Array.isArray(collect.include)) { - errors.push(`parallel node "${nodeId}" collect.include must be an array`); + errors.push(`${nodeKind} node "${nodeId}" collect.include must be an array`); return; } for (const field of collect.include) { if (typeof field !== "string" || !isParallelCollectField(field)) { - errors.push(`parallel node "${nodeId}" collect.include has invalid field "${String(field)}"`); + errors.push(`${nodeKind} node "${nodeId}" collect.include has invalid field "${String(field)}"`); } } } @@ -198,6 +285,8 @@ function isParallelCollectField(value: string): boolean { || value === "index" || value === "item" || value === "item_id" + || value === "branch_id" + || value === "branch_title" || value === "output" || value === "context_update" || value === "error" @@ -250,3 +339,15 @@ function inspectGuardRule(rule: unknown): { depth: number; nodes: number } { function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } + +function getConditionBranches(node: NodeDefinition): BranchDefinition[] { + return node.type === "condition" && Array.isArray(node.branches) + ? node.branches as BranchDefinition[] + : []; +} + +function getBranchNodeBranches(node: NodeDefinition): BranchNodeBranchDefinition[] { + return node.type === "branch" && Array.isArray(node.branches) + ? node.branches as BranchNodeBranchDefinition[] + : []; +} diff --git a/packages/common/src/store/process.ts b/packages/common/src/store/process.ts index 1cf98c4ed..21c4c4d9e 100644 --- a/packages/common/src/store/process.ts +++ b/packages/common/src/store/process.ts @@ -4,6 +4,8 @@ import { TaskField } from "./task.js"; export type JsonLogicRule = Record; export type ProcessDefinitionStatus = 'draft' | 'published' | 'archived'; +export const PROCESS_DEFINITION_FORMAT_VERSION = 1 as const; +export type ProcessDefinitionFormatVersion = typeof PROCESS_DEFINITION_FORMAT_VERSION; export type ProcessNodeType = | 'tool' @@ -11,7 +13,8 @@ export type ProcessNodeType = | 'agent' | 'process' | 'human_task' - | 'parallel' + | 'foreach' + | 'branch' | 'condition' | 'final'; @@ -19,11 +22,15 @@ export type TransitionTrigger = 'auto' | 'agent' | 'user'; export type ParallelFailurePolicy = 'fail_fast' | 'collect_errors'; export type ProcessNodeRunType = 'supervised' | 'programmatic'; export type ParallelCollectMode = 'array'; +export type BranchJoinPolicy = 'all'; +export type ProcessDefinitionMetadata = Record; export type ParallelCollectField = | 'status' | 'index' | 'item' | 'item_id' + | 'branch_id' + | 'branch_title' | 'output' | 'context_update' | 'error' @@ -36,12 +43,22 @@ export interface TransitionDefinition { guard?: JsonLogicRule; trigger?: TransitionTrigger; label?: string; + metadata?: ProcessDefinitionMetadata; } export interface BranchDefinition { to: string; when?: JsonLogicRule; default?: boolean; + metadata?: ProcessDefinitionMetadata; +} + +export interface BranchNodeBranchDefinition { + id: string; + title?: string; + description?: string; + node: NodeDefinition; + metadata?: ProcessDefinitionMetadata; } export interface HumanTaskDefinition { @@ -126,7 +143,9 @@ export interface NodeDefinition { max_concurrency?: number; collect?: string | ParallelCollectDefinition; failure_policy?: ParallelFailurePolicy; - branches?: BranchDefinition[]; + join?: BranchJoinPolicy; + branches?: BranchDefinition[] | BranchNodeBranchDefinition[]; + metadata?: ProcessDefinitionMetadata; } export interface ProcessContextDefinition { @@ -135,12 +154,14 @@ export interface ProcessContextDefinition { } export interface ProcessDefinitionBody { + format_version: ProcessDefinitionFormatVersion; process: string; description?: string; initial: string; model?: string; context: ProcessContextDefinition; nodes: Record; + metadata?: ProcessDefinitionMetadata; } export interface ProcessDefinition { @@ -165,7 +186,7 @@ export interface NodeHistoryEntry { attempt?: number; entered_at: Date | string; exited_at?: Date | string; - status: 'running' | 'completed' | 'skipped' | 'failed'; + status: 'running' | 'completed' | 'skipped' | 'failed' | 'cancelled'; context_diff: Record; data_ref?: string; sequence?: number; From dad4beba91f0321b9e840c86e3a03607327ba26a Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Fri, 24 Apr 2026 14:23:11 +0900 Subject: [PATCH 16/75] refactor: remove deprecated worker commands and related files - Deleted connect.ts, docker-credential.ts, docker.ts, index.ts, project.ts, refresh.ts, registry.ts, version.ts from the worker directory. - Updated tsconfig.json to remove references to memory-cli and memory-commands. - Cleaned up pnpm-lock.yaml by removing unused dependencies and their references. - Excluded memory-cli and memory-commands from the pnpm workspace. --- .../cli/bin/docker-credential-vertesia.js | 3 - packages/cli/package.json | 6 +- packages/cli/src/agents/index.ts | 3 + packages/cli/src/artifacts/index.ts | 2 +- packages/cli/src/codegen/CodeBuilder.ts | 106 -------- .../cli/src/codegen/InteractionVersion.ts | 104 -------- packages/cli/src/codegen/index.ts | 32 --- packages/cli/src/codegen/template.ts | 32 --- packages/cli/src/codegen/utils.ts | 4 - packages/cli/src/datagen/index.ts | 95 ------- packages/cli/src/index.ts | 36 --- packages/cli/src/interactions/index.ts | 5 +- packages/cli/src/memory/index.ts | 21 -- packages/cli/src/worker/commands.ts | 192 -------------- packages/cli/src/worker/connect.ts | 73 ----- packages/cli/src/worker/docker-credential.ts | 58 ---- packages/cli/src/worker/docker.ts | 105 -------- packages/cli/src/worker/index.ts | 98 ------- packages/cli/src/worker/project.ts | 170 ------------ packages/cli/src/worker/refresh.ts | 56 ---- packages/cli/src/worker/registry.ts | 59 ----- packages/cli/src/worker/version.ts | 4 - packages/cli/tsconfig.json | 4 +- pnpm-lock.yaml | 249 ++++++++++-------- pnpm-workspace.yaml | 2 + 25 files changed, 149 insertions(+), 1370 deletions(-) delete mode 100755 packages/cli/bin/docker-credential-vertesia.js delete mode 100644 packages/cli/src/codegen/CodeBuilder.ts delete mode 100644 packages/cli/src/codegen/InteractionVersion.ts delete mode 100644 packages/cli/src/codegen/index.ts delete mode 100644 packages/cli/src/codegen/template.ts delete mode 100644 packages/cli/src/codegen/utils.ts delete mode 100644 packages/cli/src/datagen/index.ts delete mode 100644 packages/cli/src/memory/index.ts delete mode 100644 packages/cli/src/worker/commands.ts delete mode 100644 packages/cli/src/worker/connect.ts delete mode 100644 packages/cli/src/worker/docker-credential.ts delete mode 100644 packages/cli/src/worker/docker.ts delete mode 100644 packages/cli/src/worker/index.ts delete mode 100644 packages/cli/src/worker/project.ts delete mode 100644 packages/cli/src/worker/refresh.ts delete mode 100644 packages/cli/src/worker/registry.ts delete mode 100644 packages/cli/src/worker/version.ts diff --git a/packages/cli/bin/docker-credential-vertesia.js b/packages/cli/bin/docker-credential-vertesia.js deleted file mode 100755 index 28614be8d..000000000 --- a/packages/cli/bin/docker-credential-vertesia.js +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env node - -import '../lib/agent/docker-credential.js' diff --git a/packages/cli/package.json b/packages/cli/package.json index 1ec2a149f..9738db3fa 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -4,8 +4,7 @@ "description": "The Vertesia command-line interface (CLI) provides a set of commands to manage and interact with the Vertesia Platform.", "type": "module", "bin": { - "vertesia": "./bin/app.js", - "docker-credential-vertesia": "./bin/docker-credential-vertesia.js" + "vertesia": "./bin/app.js" }, "main": "./lib/index.js", "types": "./lib/index.d.ts", @@ -35,8 +34,6 @@ "@llumiverse/common": "workspace:*", "@vertesia/client": "workspace:*", "@vertesia/common": "workspace:*", - "@vertesia/memory-cli": "workspace:*", - "@vertesia/memory-commands": "workspace:*", "@vertesia/workflow": "workspace:*", "ansi-colors": "^4.1.3", "ansi-escapes": "^6.2.0", @@ -49,7 +46,6 @@ "figures": "^6.1.0", "glob": "^11.1.0", "gradient-string": "^3.0.0", - "json-schema-to-typescript": "^15.0.4", "jsonwebtoken": "^9.0.3", "log-symbols": "^7.0.0", "log-update": "^6.1.0", diff --git a/packages/cli/src/agents/index.ts b/packages/cli/src/agents/index.ts index 7a80ca45d..046441927 100644 --- a/packages/cli/src/agents/index.ts +++ b/packages/cli/src/agents/index.ts @@ -17,6 +17,7 @@ import chalk from "chalk"; import { Command } from "commander"; import { setTimeout as delay } from "node:timers/promises"; import * as readline from "readline"; +import { registerArtifactsCommand } from "../artifacts/index.js"; import { getClient } from "../client.js"; import { readFile, readStdin, writeFile } from "../utils/stdio.js"; @@ -89,6 +90,8 @@ export function registerAgentsCommand(program: Command) { const agents = program.command("agents") .description("Start, stream, and inspect durable agent runs"); + registerArtifactsCommand(agents); + agents.command("start [interaction]") .description("Start a conversation agent or process run through the Agent Runs API") .option("-d, --data ", "Inline input data as a JSON object") diff --git a/packages/cli/src/artifacts/index.ts b/packages/cli/src/artifacts/index.ts index 69a5e40d1..64b62911d 100644 --- a/packages/cli/src/artifacts/index.ts +++ b/packages/cli/src/artifacts/index.ts @@ -3,7 +3,7 @@ import { downloadArtifact, getArtifactUrl, listArtifacts, uploadArtifact } from export function registerArtifactsCommand(program: Command) { const artifacts = program.command("artifacts") - .description("Manage agent artifacts. Run ID can be inferred from VERTESIA_RUN_ID env var."); + .description("Manage artifacts for agent runs. Run ID can be inferred from VERTESIA_RUN_ID env var."); artifacts.command("upload [file]") .description("Upload an artifact to the current agent run. Use '-' or omit file to read from stdin.") diff --git a/packages/cli/src/codegen/CodeBuilder.ts b/packages/cli/src/codegen/CodeBuilder.ts deleted file mode 100644 index 8c6aeb8ac..000000000 --- a/packages/cli/src/codegen/CodeBuilder.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { InteractionRefWithSchema } from "@vertesia/common"; -import { join, resolve } from "path"; -import { makeDir, writeFile } from "../utils/stdio.js"; -import { InteractionVersion, InteractionsExportOptions } from "./InteractionVersion.js"; -import { processTemplate } from "./template.js"; - - -export class InteractionBucket { - - versions: InteractionVersion[] = []; - - constructor(public name: string) { - } - - add(version: InteractionVersion) { - this.versions.push(version); - return this; - } - - writeIndex(dir: string, opts: InteractionsExportOptions) { - let version: InteractionVersion | undefined; - const exportVersion = opts.exportVersion ? parseInt(opts.exportVersion) : undefined; - if (exportVersion) { - version = this.versions.find(version => exportVersion === version.interaction.version); - if (!version) { - console.error(`No export version ${exportVersion} found for interaction ${this.name}. Using default export version`); - } - } - if (!version) { - // default index export the default - let draft: InteractionVersion | undefined; - let latest: InteractionVersion | undefined; - let latestFallback: InteractionVersion | undefined; - for (const version of this.versions) { - if (version.isDraft) { - draft = version; - } else if (version.isLatest) { - latest = version; - } else { - if (latestFallback) { - if (latestFallback.interaction.version < version.interaction.version) { - latestFallback = version; - } - } else { - latestFallback = version; - } - } - } - version = latest || latestFallback || draft; - } - if (version) { - const file = join(dir, 'index.ts'); - writeFile(file, processTemplate('index', { - versionName: version.versionName - })); - } else { - console.error('No default version found for interaction', this.name); - } - } - - build(dir: string, opts: InteractionsExportOptions) { - console.log('Generating interaction', this.name); - dir = join(dir, this.name); - makeDir(dir); - for (const version of this.versions) { - version.build(dir, opts); - } - this.writeIndex(dir, opts); - } -} - -export class CodeBuilder { - buckets: Record = {}; - - add(interaction: InteractionRefWithSchema) { - const version = new InteractionVersion(interaction); - const name = version.className; - const bucket = this.buckets[name]; - if (bucket) { - bucket.add(version); - } else { - this.buckets[name] = new InteractionBucket(name).add(version); - } - } - - build(interactions: InteractionRefWithSchema[], opts: InteractionsExportOptions) { - if (interactions.length === 0) { - console.log("Nothing to export"); - return; - } - - const dir = resolve(opts.dir); - // create dir if not exists - makeDir(dir); - - for (const interaction of interactions) { - this.add(interaction); - } - - console.log("Generating code in", dir); - for (const bucket of Object.values(this.buckets)) { - bucket.build(dir, opts); - } - } - -} \ No newline at end of file diff --git a/packages/cli/src/codegen/InteractionVersion.ts b/packages/cli/src/codegen/InteractionVersion.ts deleted file mode 100644 index 6dfe872d7..000000000 --- a/packages/cli/src/codegen/InteractionVersion.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { InteractionRefWithSchema, InteractionStatus, mergePromptsSchema } from "@vertesia/common"; -import { compile as compileSchema } from 'json-schema-to-typescript'; -import { join } from 'path'; -import { writeFile } from "../utils/stdio.js"; -import { processTemplate } from "./template.js"; -import { textToPascalCase } from "./utils.js"; - -export interface InteractionsExportOptions { - dir: string, - project: string, - exportVersion?: string, -} - -export interface InteractionTemplateVars extends Record { - id: string; - doc: string; - className: string; - projectId: string; // the project id - inputType: string; - outputType: string; - types: string; - date: string; -} - -export function processInteractionTemplate(vars: InteractionTemplateVars) { - return processTemplate('interaction', { ...vars, date: new Date().toISOString() }); -} - -export class InteractionVersion { - - isDraft: boolean; - className: string; - - constructor(public interaction: InteractionRefWithSchema) { - this.isDraft = interaction.status === InteractionStatus.draft; - this.className = textToPascalCase(interaction.name || '__Unknown'); - } - - get isLatest() { - //TODO we no more support the latest version - return false; - //return this.interaction.latest; - } - - get versionName() { - return this.isDraft ? 'draft' : `v${this.interaction.version}`; - } - get fileName() { - return `${this.versionName}.ts`; - } - - async build(dir: string, opts: InteractionsExportOptions) { - const file = join(dir, this.fileName); - writeFile(file, await this.genCode(opts)); - return file; - } - - async genCode(opts: InteractionsExportOptions) { - const interaction = this.interaction; - const className = this.className; - const inputSchema = mergePromptsSchema(interaction); - const outputSchema = interaction.result_schema; - - const out = []; - const types = []; - let inputTypeName = "any"; - let outputTypeName = "any"; - if (inputSchema) { - inputTypeName = className + 'Props'; - const schemaType = await compileSchema(inputSchema, inputTypeName, { - bannerComment: '', - style: { - tabWidth: 4, - }, - additionalProperties: false - }); - types.push(`/**\n * ${interaction.name} input type\n */`, schemaType); - } - if (outputSchema) { - outputTypeName = className + 'Result'; - const schemaType = await compileSchema(outputSchema, outputTypeName, { - bannerComment: '', - style: { - tabWidth: 4, - }, - additionalProperties: false - }); - types.push(`/**\n * ${interaction.name} result type\n */`, schemaType); - } - const vars: InteractionTemplateVars = { - className: className, - id: interaction.id, - projectId: opts.project, - inputType: inputTypeName, - outputType: outputTypeName, - doc: interaction.description ? `${interaction.name}\n${interaction.description}` : interaction.name, - date: new Date().toISOString(), - types: types.join('\n') - }; - out.push(processInteractionTemplate(vars)); - - return out.join('\n'); - } -} \ No newline at end of file diff --git a/packages/cli/src/codegen/index.ts b/packages/cli/src/codegen/index.ts deleted file mode 100644 index 0d8fdd6a2..000000000 --- a/packages/cli/src/codegen/index.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Command } from "commander"; -import { getClient } from "../client.js"; -import { CodeBuilder } from "./CodeBuilder.js"; - -export default async function runExport(program: Command, interactionName: string | undefined, options: Record) { - const client = await getClient(program); - const project = await client.getProject(); - if (!project) { - console.error('No project id specified'); - process.exit(1); - } - - const tags = options.tags ? options.tags.split(/\s*,\s*/) : undefined; - const versions = options.versions ? options.versions.split(/\s*,\s*/) : ["draft"]; - - const payload = { - name: interactionName, - tags: tags, - versions: options.all ? [] : versions, - } - try { - const interactions = await client.interactions.export(payload); - new CodeBuilder().build(interactions, { - dir: options.dir, - project: project.id, - exportVersion: options.export || undefined, - }); - } catch (error: any) { - console.error('Failed to export interactions:', error.message || error); - process.exit(1); - } -} diff --git a/packages/cli/src/codegen/template.ts b/packages/cli/src/codegen/template.ts deleted file mode 100644 index 2f07cb0b9..000000000 --- a/packages/cli/src/codegen/template.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { readFileSync } from 'fs'; -import { packageDir } from '../package.js'; - -const EXPR_RX = /\{\{\s*[a-zA-Z_][a-zA-Z_0-9]*\s*\}\}/g - -const TEMPLATES: Record = {}; - -export function expandVariables(template: string, vars: Record) { - return template.replace(EXPR_RX, (match) => { - const key = match.substring(2, match.length - 2).trim() - if (key in vars) { - return vars[key] || '' - } else { - return match; - } - }) -} - -function loadTemplate(name: string) { - let content = TEMPLATES[name]; - if (!content) { - content = readFileSync(`${packageDir}/templates/${name}.tpl`, 'utf8') - TEMPLATES[name] = content; - } - return content; -} - -export function processTemplate(name: string, vars: Record) { - const content = loadTemplate(name); - return expandVariables(content, vars); -} - diff --git a/packages/cli/src/codegen/utils.ts b/packages/cli/src/codegen/utils.ts deleted file mode 100644 index 99dff6bc7..000000000 --- a/packages/cli/src/codegen/utils.ts +++ /dev/null @@ -1,4 +0,0 @@ - -export function textToPascalCase(text: string) { - return text.trim().split(/\W/).map(w => w ? w[0].toUpperCase() + w.substring(1) : '').join('') -} diff --git a/packages/cli/src/datagen/index.ts b/packages/cli/src/datagen/index.ts deleted file mode 100644 index 790d92356..000000000 --- a/packages/cli/src/datagen/index.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { Command } from "commander"; -import { resolve } from "path"; -import { getClient } from "../client.js"; -import { Spinner, restoreCursorOnExit } from "../utils/console.js"; -import { writeJsonFile } from "../utils/stdio.js"; -import { ConfigModes } from "@vertesia/common"; -import { TextFallbackOptions } from "@llumiverse/common"; - -function convertConfigMode(raw_config_mode: any): ConfigModes | undefined { - const configStr: string = typeof raw_config_mode === 'string' ? raw_config_mode.toUpperCase() : ""; - return Object.values(ConfigModes).includes(configStr as ConfigModes) ? configStr as ConfigModes : undefined; -} - -export async function genTestData(program: Command, interactionId: string, options: Record) { - const count = options.count ? parseInt(options.count) : 1; - const message = options.message || undefined; - const output = options.output || undefined; - const spinner = new Spinner('dots'); - spinner.prefix = "Generating data. Please be patient "; - - // Create abort controller for handling interruption - const abortController = new AbortController(); - const { signal } = abortController; - - // Set up cleanup function - const cleanup = () => { - spinner.done(false); - console.log("\nData generation interrupted"); - process.exit(0); - }; - - // Set up signal handlers - const handleSignal = () => { - abortController.abort(); - cleanup(); - }; - - process.on('SIGINT', handleSignal); - process.on('SIGTERM', handleSignal); - - spinner.start(); - - //TODO: Support for other modalities, like images - const model_options: TextFallbackOptions = { - _option_id: "text-fallback", - temperature: typeof options.temperature === 'string' ? parseFloat(options.temperature) : undefined, - max_tokens: typeof options.maxTokens === 'string' ? parseInt(options.maxTokens) : undefined, - top_p: typeof options.topP === 'string' ? parseFloat(options.topP) : undefined, - top_k: typeof options.topK === 'string' ? parseInt(options.topK) : undefined, - presence_penalty: typeof options.presencePenalty === 'string' ? parseFloat(options.presencePenalty) : undefined, - frequency_penalty: typeof options.frequencyPenalty === 'string' ? parseFloat(options.frequencyPenalty) : undefined, - stop_sequence: options.stopSequence ? options.stopSequence.trim().split(/\s*,\s*/) : undefined, - }; - - const client = await getClient(program); - client.interactions.generateTestData(interactionId, { - count, - message, - config: { - environment: options.env, - model: options.model || undefined, - model_options: model_options, - configMode: convertConfigMode(options.configMode), - } - // Pass abort signal if the API supports it - // signal - }).then((result) => { - // Remove signal handlers - process.off('SIGINT', handleSignal); - process.off('SIGTERM', handleSignal); - - spinner.done(); - if (output) { - const file = resolve(output); - writeJsonFile(file, result); - console.log('Data saved in: ', output); - } - console.log(); - console.log(result); - }).catch(err => { - // Remove signal handlers - process.off('SIGINT', handleSignal); - process.off('SIGTERM', handleSignal); - - // Don't show error if aborted - if (signal.aborted) { - return; - } - - spinner.done(false); - console.log('Failed to generate data:', err.message); - }); -} - -restoreCursorOnExit(); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index b489f87f4..a024e698d 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,13 +1,8 @@ -import { setupMemoCommand } from '@vertesia/memory-cli'; import { Command } from 'commander'; import { registerAppsCommand } from './apps/index.js'; import { registerAgentsCommand } from './agents/index.js'; -import { registerArtifactsCommand } from './artifacts/index.js'; -import runExport from './codegen/index.js'; -import { genTestData } from './datagen/index.js'; import { listEnvironments } from './envs/index.js'; import { listInteractions } from './interactions/index.js'; -import { getPublishMemoryAction } from './memory/index.js'; import { registerObjectsCommand } from './objects/index.js'; import { getVersion, upgrade } from './package.js'; import { createProfile, deleteProfile, listProfiles, showActiveAuthToken, showProfile, tryRefreshToken, updateCurrentProfile, updateProfile, useProfile, type CreateProfileOptions } from './profiles/commands.js'; @@ -15,7 +10,6 @@ import { AVAILABLE_REGIONS, DEFAULT_REGION, getConfigFile } from './profiles/ind import { listProjects } from './projects/index.js'; import runInteraction from './run/index.js'; import { runHistory } from './runs/index.js'; -import { registerWorkerCommand } from './worker/index.js'; import { registerWorkflowsCommand } from './workflows/index.js'; //warnIfNotLatest(); @@ -55,31 +49,6 @@ program.command("interactions [interaction]") .action((interactionId: string | undefined, options: Record) => { listInteractions(program, interactionId, options); }) -program.command("datagen ") - .description("Generate test input data, given an interaction ID") - .option('-e, --env [envId]', 'The environment ID to use to generating the test data') - .option('-m, --model [model]', 'The model to use to generating the test data. If the selected environment has a default model then this option is optional.') - .option('-t, --temperature [value]', 'The temperature used to generating the test data.') - .option('--max-tokens [max-tokens]', 'The maximum number of tokens to generate') - .option('--top-p [top-p]', 'The top P value to use') - .option('--top-k [top-k]', 'The top K value to use') - .option('--presence-penalty [presence-penalty]', 'The presence penalty value to use') - .option('--frequency-penalty [frequency-penalty]', 'The frequency penalty value to use') - .option('--stop-sequence [stop-sequence]', 'A comma separated list of sequences to stop the generation') - .option('--config-mode [config-mode]', 'The configuration mode to use.Possible values are: "run_and_interaction_config", "run_config_only", "interaction_config_only". Optional. If not specified, "run_and_interaction_config" is used.') - .option('-o, --output [file]', 'A file to save the generated test data. If not specified the data will be printed to stdout.') - .option('-c, --count [value]', 'The number of data objects to generate', '1') - .option('--message [value]', 'An optional message') - .action((interactionId: string, options: Record) => { - genTestData(program, interactionId, options); - }) -program.command("codegen [interactionName]") - .description("Generate code given an interaction name of for all the interactions in the project if no interaction is specified.") - .option('--versions [versions]', 'A comma separated list of version selectors to include. A version selector is either a version number or "draft". The default is "draft"', "draft") - .option('-a, --all', 'When used, all the interaction versions will be exported') - .option('-d, --dir [file]', 'The output directory if any. Default to "./interactions" if not specified.', './interactions') - .option('-x, --export ', 'The version to export from index.ts. If not specified, the latest version will be exported or if no version is available, the draft version will be exported') - .action((interactionName: string | undefined, options) => runExport(program, interactionName, options)); program.command("run ") .description("Run an interaction by full name. The full name is composed by an optional namespace, a required endpoint name and an optional tag or version. Examples: name, namespace:name, namespace:name@version") .option('-i, --input [file]', 'The input data if any. If no file path is specified it will read from stdin') @@ -121,13 +90,8 @@ program.command("runs [interactionId]") runHistory(program, interactionId, options); }); -const memoCmd = program.command("memo"); -setupMemoCommand(memoCmd, getPublishMemoryAction(program)); - -registerWorkerCommand(program); registerAppsCommand(program); registerAgentsCommand(program); -registerArtifactsCommand(program); const profilesRoot = program.command("profiles") .description("Manage configuration profiles") diff --git a/packages/cli/src/interactions/index.ts b/packages/cli/src/interactions/index.ts index 6fe534ee9..11501c61c 100644 --- a/packages/cli/src/interactions/index.ts +++ b/packages/cli/src/interactions/index.ts @@ -2,7 +2,10 @@ import { Interaction, InteractionRef, InteractionStatus } from "@vertesia/common import colors from "ansi-colors"; import { Command } from "commander"; import { getClient } from "../client.js"; -import { textToPascalCase } from "../codegen/utils.js"; + +function textToPascalCase(text: string) { + return text.trim().split(/\W/).map(w => w ? w[0].toUpperCase() + w.substring(1) : '').join('') +} export async function listInteractions(program: Command, interactionId: string | undefined, options: Record) { const client = await getClient(program); diff --git a/packages/cli/src/memory/index.ts b/packages/cli/src/memory/index.ts deleted file mode 100644 index 988b3338c..000000000 --- a/packages/cli/src/memory/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { VertesiaClient } from "@vertesia/client"; -import { NodeStreamSource } from "@vertesia/client/node"; -import { Command } from "commander"; -import { createReadStream } from "fs"; -import { getClient } from "../client.js"; - -export function getPublishMemoryAction(program: Command) { - return async (file: string, name: string) => { - const client = await getClient(program); - return publishMemory(client, file, name); - } -} - -async function publishMemory(client: VertesiaClient, file: string, name: string) { - const stream = createReadStream(file); - const path = await client.files.uploadMemoryPack(new NodeStreamSource(stream, - name, - "application/gzip" - )); - return path; -} diff --git a/packages/cli/src/worker/commands.ts b/packages/cli/src/worker/commands.ts deleted file mode 100644 index 3be951b79..000000000 --- a/packages/cli/src/worker/commands.ts +++ /dev/null @@ -1,192 +0,0 @@ -import colors from "ansi-colors"; -import fs from "fs"; -import os from "os"; -import { join } from "path"; -import { getClient } from "../client.js"; -import { config, getCloudTypeFromConfigUrl, Profile } from "../profiles/index.js"; -import { runDocker, runDockerWithOutput, runDockerWithWorkerConfig } from "./docker.js"; -import { WorkerProject } from './project.js'; -import { tryRefreshProjectToken } from './refresh.js'; -import { updateNpmrc } from "./registry.js"; -import { validateVersion } from './version.js'; - -export enum PublishMode { - Push = 1, - Deploy = 2, - PushAndDeploy = 3 -} - -function shouldDeploy(mode: PublishMode) { - return mode & PublishMode.Deploy; -} - -function shouldPush(mode: PublishMode) { - return mode & PublishMode.Push; -} - -// we need to build for the linux/amd64 platform (this is the platform used in Vertesia k8s) -const TARGET_PLATFORM = "linux/amd64"; -const LATEST_VERSION = "latest"; - -async function pushImage(project: WorkerProject, version: string) { - - // we need to refresh the profile token if needed since - // the docker credentials helper are connecting to studio. - // this will refresh the profile token if needed by asking the user to reconnect - await tryRefreshProjectToken(project); - - const localTag = project.getLocalDockerTag(version); - const remoteTag = project.getVertesiaDockerTag(version); - - // push the image to the registry - console.log(`Pushing docker image ${remoteTag}`); - runDocker(['tag', localTag, remoteTag]); - runDockerWithWorkerConfig(['push', remoteTag]); -} - -async function triggerDeploy(profile: Profile, project: WorkerProject, version: string) { - const environment = getCloudTypeFromConfigUrl(profile.config_url); - const client = await getClient(); - const workerId = project.getWorkerId(); - console.log(`Deploy worker ${workerId}:${version} to ${environment}`); - await client.store.workers.deploy({ - environment, - workerId: project.getWorkerId(), - version, - }); -} - -export async function publish(version: string, mode: PublishMode) { - if (!validateVersion(version)) { - console.log("Invalid version format: " + version + ". Use major.minor.patch[-modifier] format."); - process.exit(1); - } - if (!config.current) { - console.log("No active profile is defined."); - process.exit(1); - } - const profile = config.current; - const project = new WorkerProject(); - if (shouldPush(mode)) { - await pushImage(project, version); - } - if (shouldDeploy(mode)) { - await triggerDeploy(profile, project, version); - } -} - -export async function build(contextDir = '.') { - const project = new WorkerProject(); - - const refreshResult = await tryRefreshProjectToken(project); - if (refreshResult) { - await updateNpmrc(project, refreshResult.profile); - } - - const tag = project.getLocalDockerTag(LATEST_VERSION); - const args = ['buildx', 'build', '--platform', TARGET_PLATFORM, '-t', tag]; - if (contextDir !== '.') { // not the working directory - // use the dockerfile in the current working directory - args.push('-f', 'Dockerfile') - } - console.log(`Building docker image: ${tag}`); - runDocker([...args, contextDir]); -} - -export async function release(version: string) { - if (!validateVersion(version)) { - console.log("Invalid version format: " + version + ". Use major.minor.patch[-modifier] format."); - process.exit(1); - } - const project = new WorkerProject(); - const latestTag = project.getLocalDockerTag(LATEST_VERSION); - const versionTag = project.getLocalDockerTag(version); - - runDocker(['tag', latestTag, versionTag]); -} - -export function run(version: string = LATEST_VERSION) { - if (version !== LATEST_VERSION && !validateVersion(version)) { - console.log("Invalid version format: " + version + ". Use major.minor.patch[-modifier] format."); - process.exit(1); - } - const project = new WorkerProject(); - const tag = project.getLocalDockerTag(version); - // we need to inject the .env file into the container - // and to get the google credentials needed by the worker - // TODO: the google credentials will only work with vertesia users ... - // we need to specify the target platform to force qemu emulation for user on other platforms - const args = ['run', '--platform', TARGET_PLATFORM, '--env-file', '.env']; - const googleCredsFile = getGoogleCredentialsFile(); - if (googleCredsFile) { - args.push('-v', `${googleCredsFile}:/tmp/google-credentials.json`); - args.push('-e', 'GOOGLE_APPLICATION_CREDENTIALS=/tmp/google-credentials.json'); - } - args.push(tag) - runDocker(args); -} - -export async function listVersions() { - const project = new WorkerProject(); - if (!project.packageJson.vertesia?.image) { - console.error("Invalid package.json. Missing vertesia.image configuration."); - process.exit(1); - } - const out = runDockerWithOutput(["images", "--format", "{{.Repository}}:{{.Tag}}"]); - const lines = out.trim().split('\n'); - const localTagPrefix = project.getLocalDockerTag(""); - const remoteTagPrefix = project.getVertesiaDockerTag(""); - const localTags: Record = {}; - const remoteTags: Record = {}; - const versions = new Set(); - for (const line of lines) { - const i = line.indexOf(':'); - if (i > 0) { - const name = line.substring(0, i); - const version = line.substring(i + 1); - if (line.startsWith(localTagPrefix)) { - localTags[version] = { version, name }; - versions.add(version); - } else if (line.startsWith(remoteTagPrefix)) { - remoteTags[version] = { version, name }; - versions.add(version); - } // else ignore - } - } - - versions.delete(LATEST_VERSION); - printVersion(LATEST_VERSION, localTags[LATEST_VERSION], remoteTags[LATEST_VERSION]); - // sort desc - Array.from(versions).sort((a, b) => b.localeCompare(a)).forEach(v => { - printVersion(v, localTags[v], remoteTags[v]); - }); - -} - -function printVersion(version: string, local: TagInfo | undefined, remote: TagInfo | undefined) { - if (!local && !remote) return; - const isLatest = version === LATEST_VERSION; - console.log(colors.bold(isLatest ? version : `v${version}`)); - console.log(`\t${colors.green("Local tag:")} ${local ? local.name + ':' + version : 'N/A'}`); - if (remote) { - console.log(`\t${colors.green("published to:")} ${remote.name}:${version}`); - } else if (isLatest) { - console.log(colors.dim("\tcannot be published")); - } else { - console.log(colors.dim("\tnot published")); - } -} - -interface TagInfo { - version: string; - name: string; -} - -function getGoogleCredentialsFile() { - const file = join(os.homedir(), '.config/gcloud/application_default_credentials.json'); - if (fs.existsSync(file)) { - return file; - } else { - return null; - } -} \ No newline at end of file diff --git a/packages/cli/src/worker/connect.ts b/packages/cli/src/worker/connect.ts deleted file mode 100644 index 593baaa95..000000000 --- a/packages/cli/src/worker/connect.ts +++ /dev/null @@ -1,73 +0,0 @@ -import enquirer from "enquirer"; -import { createProfile, refreshProfile } from "../profiles/commands.js"; -import { config, shouldRefreshProfileToken } from "../profiles/index.js"; -import { ConfigResult } from "../profiles/server/index.js"; -import { WorkerProject } from "./project.js"; -import { updateNpmrc } from "./registry.js"; - -const { prompt } = enquirer; - -interface ConnectOptions { - nonInteractive?: boolean; - profile?: string; -} -export async function connectToProject(options: ConnectOptions) { - const allowInteraction = !options.nonInteractive; - const project = new WorkerProject(); - const pkg = project.packageJson; - let profileName: string | undefined = options.profile || pkg.vertesia.profile; - const onAuthenticationDone = async (result: ConfigResult | undefined) => { - if (result) { - await updateNpmrc(project, result.profile); - } - } - try { - if (allowInteraction && !profileName) { - profileName = await askProfileName(); - if (!profileName) { - // create a new profile - profileName = await createProfile(undefined, { onResult: onAuthenticationDone }); - } - } - if (!profileName) { - console.log('Profile not specified. When using --non-interactive mode you may want to specify a profile'); - process.exit(1); - } - const profile = config.getProfile(profileName); - if (!profile) { - console.log('Profile not found:', profileName); - process.exit(1); - } - if (allowInteraction && shouldRefreshProfileToken(profile, 10)) { - console.log("Refreshing auth token for profile:", profileName); - await refreshProfile(profileName, onAuthenticationDone); - } else { - await updateNpmrc(project, profileName); - } - } finally { - if (pkg.vertesia.profile !== profileName) { - // save package.json - pkg.vertesia.profile = profileName; - pkg.save(); - } - } -} - -async function askProfileName() { - const profiles = config.profiles.map(p => p.name); - if (profiles.length > 0) { - const newProfile = 'Create new profile'; - profiles.push(newProfile); - const answer: Record = await prompt({ - name: 'profile', - type: 'select', - message: "Select a profile to use when connecting.", - initial: profiles.length - 1, - choices: profiles, - }); - if (newProfile !== answer.profile) { - return answer.profile; // use existing - } - } - return undefined; // create new profile -} diff --git a/packages/cli/src/worker/docker-credential.ts b/packages/cli/src/worker/docker-credential.ts deleted file mode 100644 index 0f4588a09..000000000 --- a/packages/cli/src/worker/docker-credential.ts +++ /dev/null @@ -1,58 +0,0 @@ -import fs from "node:fs"; -import { getDockerCredentials } from "./docker.js"; - -// Function to handle other commands (`store`, `erase`, `list`) -function handleNoOp() { - // Docker expects these commands to exit with 0 - process.exit(0); -} - -// Function to handle the `get` command -async function handleGet(serverUrl: string) { - // we support us-docker.pkg.dev for now - if (!serverUrl.endsWith("-docker.pkg.dev")) { - process.exit(0); // ignore - } - try { - const credentials = await getDockerCredentials(serverUrl); - if (process.env.DEBUG_DOCKER_CREDS) { - fs.writeFileSync("./docker-creds-helper.log", "Get token for registry " + serverUrl + " => " + JSON.stringify(credentials, null, 2), "utf8"); - } - process.stdout.write(JSON.stringify(credentials)); - } catch (error: any) { - fs.writeFileSync("./docker-creds-helper-error.log", "Get token for registry " + serverUrl + " => " + JSON.stringify(error, null, 2), "utf8"); - console.error("Error fetching credentials:", error.message); - process.exit(1); - } -} - - -// Main function to handle input -function main() { - const command = process.argv[2]; - - if (!command) { - console.error("No command provided"); - process.exit(1); - } - - switch (command) { - case "get": { - const stdin = fs.readFileSync(0, "utf-8"); - handleGet(stdin.trim()); - break; - } - - case "store": - case "erase": - case "list": - handleNoOp(); - break; - - default: - console.error(`Unknown command: ${command}`); - process.exit(1); - } -} - -main(); \ No newline at end of file diff --git a/packages/cli/src/worker/docker.ts b/packages/cli/src/worker/docker.ts deleted file mode 100644 index 41713552c..000000000 --- a/packages/cli/src/worker/docker.ts +++ /dev/null @@ -1,105 +0,0 @@ -import ansiColors from "ansi-colors"; -import { spawnSync } from "node:child_process"; -import { existsSync } from "node:fs"; -import { join } from "node:path"; -import { getClient } from "../client.js"; -import { config } from "../profiles/index.js"; -import { WorkerProject } from "./project.js"; - - -const LOCAL_DOCKER_CONFIG_DIR = '.docker'; - -export function generateDockerConfig() { - return JSON.stringify({ - "credHelpers": { - "us-central1-docker.pkg.dev": "vertesia" - } - }, undefined, 2); -} - - -async function getGoogleToken(pkgDir?: string) { - const project = new WorkerProject(pkgDir); - const pkg = project.packageJson; - if (!pkg.vertesia.profile) { - throw new Error("Profile entry not found in package.json"); - } - config.use(pkg.vertesia.profile); // will exit if profile not found - const client = await getClient(); - const r = await client.account.getGoogleToken(); - return r.token; -} - -export async function getDockerCredentials(serverUrl: string) { - const token = await getGoogleToken(); - return { - ServerURL: serverUrl, - Username: "oauth2accesstoken", - Secret: token, - }; -} - -export function runDockerWithWorkerConfig(args: string[]) { - const config = join(process.cwd(), LOCAL_DOCKER_CONFIG_DIR); - const baseArgs: string[] = []; - if (existsSync(config)) { - baseArgs.push("--config", config); - } - return runDocker(baseArgs.concat(args)); -} - -export function runDocker(args: string[]) { - const verbose = process.argv.includes("--verbose"); - if (verbose) { - const cmd = `docker ${args.join(' ')}`; - console.log(`Running: ${ansiColors.magenta(cmd)}`); - } - const r = spawnSync('docker', args, { - stdio: 'inherit', - env: { - ...process.env, - DOCKER_BUILDKIT: "1" - } - }); - // check for errors - if (r.error) { - console.error(`Failed to execute command "docker ${args.join(' ')}":`, r.error); - process.exit(2); - } - // Check for non-zero exit code - if (r.status !== 0) { - console.error( - `Command "docker ${args.join(' ')}" failed with exit code ${r.status}` - ); - process.exit(r.status ?? 1); - } - return r; -} - -export function runDockerWithOutput(args: string[]) { - const verbose = process.argv.includes("--verbose"); - if (verbose) { - const cmd = `docker ${args.join(' ')}`; - console.log(`Running: ${ansiColors.magenta(cmd)}`); - } - const r = spawnSync('docker', args, { - encoding: 'utf-8', - env: { - ...process.env, - DOCKER_BUILDKIT: "1" - } - }); - // check for errors - if (r.error) { - console.error(`Failed to execute command "docker ${args.join(' ')}":`, r.error); - process.exit(2); - } - // Check for non-zero exit code - if (r.status !== 0) { - console.error( - `Command "docker ${args.join(' ')}" failed with exit code ${r.status}\n${r.stderr}` - ); - process.exit(r.status ?? 1); - } - return r.stdout.trim(); -} diff --git a/packages/cli/src/worker/index.ts b/packages/cli/src/worker/index.ts deleted file mode 100644 index f5a5844b5..000000000 --- a/packages/cli/src/worker/index.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { Command } from "commander"; -import { build, listVersions, publish, PublishMode, release, run } from "./commands.js"; -import { connectToProject } from "./connect.js"; -import { getGooglePrincipal, getGoogleToken } from "./registry.js"; - -export function registerWorkerCommand(program: Command) { - const worker = program.command("worker") - .description("Build, release, and deploy custom workflow workers"); - - worker.command("connect [pkgDir]") - .description("Connect a node package to a Vertesia project. If no packageDir is specified the current dir will be used.") - .option("-I, --non-interactive", "Don't do interactions with the user. Assume the user is already authenticated.") - .option("-p, --profile [profile]", "The profile name to use. If not specified the one from the package.json will be used.") - .action(async (pkgDir: string, options: Record = {}) => { - if (pkgDir) { - process.chdir(pkgDir); - } - await connectToProject(options); - }); - - worker.command("publish ") - .description("Deploy a custom workflow worker. The user will be asked for a target image version.") - .option("-d, --dir [project_dir]", "Use this as the current directory.") - .option("--push-only", "If used the docker image will be push only. The deployment will not be triggered.") - .option("--deploy-only", "If used the docker is assumed to be already pushed and only the deploy will be triggered.") - .option("--verbose", "Print more information.") - //.option("-p, --profile [profile]", "The profile name to use. If not specified the one from the package.json will be used.") - .action(async (version: string, options: Record = {}) => { - if (options.dir) { - process.chdir(options.dir); - } - let mode: PublishMode; - if (options.pushOnly) { - mode = PublishMode.Push; - } else if (options.deployOnly) { - mode = PublishMode.Deploy - } else { - mode = PublishMode.PushAndDeploy; - } - await publish(version, mode); - }); - - worker.command("build") - .description("Build a local docker image using 'latest' as version.") - .option("-d, --dir [project_dir]", "Use this as the current directory.") - .option("-c, --context [context]", "The docker build context to use. Defaults to the project directory.") - .option("--verbose", "Print more information.") - .action(async (options: Record) => { - if (options.dir) { - process.chdir(options.dir); - } - await build(options.context); - }); - - worker.command("release [version]") - .description("Promote the latest version to a named version (tag it).") - .option("-d, --dir [project_dir]", "Use this as the current directory.") - .option("--verbose", "Print more information.") - .action(async (version: string, options: Record) => { - if (options.dir) { - process.chdir(options.dir); - } - await release(version); - }); - - worker.command("run [version]") - .description("Run the docker image identified by the given version or the 'latest' version if no version is given.") - .option("-d, --dir [project_dir]", "Use this as the current directory.") - .option("--verbose", "Print more information.") - .action(async (version: string, options: Record) => { - if (options.dir) { - process.chdir(options.dir); - } - await run(version); - }); - - worker.command("versions") - .description("List existing versions.") - .option("--verbose", "Print more information.") - .action(async (_options: Record) => { - await listVersions(); - }); - - worker.command("gtoken") - .description("Get a google cloud token for the current vertesia project.") - .option("-p, --profile", "The profile name to use. If specified it will be used instead of the current profile.") - .action(async (options: Record = {}) => { - await getGoogleToken(program, options.profile); - }); - - worker.command("gprincipal") - .description("Get the google cloud principal for the current project.") - .option("-p, --profile", "The profile name to use. If specified it will be used instead of the current profile.") - .action(async (options: Record = {}) => { - await getGooglePrincipal(program, options.profile); - }); - -} \ No newline at end of file diff --git a/packages/cli/src/worker/project.ts b/packages/cli/src/worker/project.ts deleted file mode 100644 index 311f0a66f..000000000 --- a/packages/cli/src/worker/project.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { existsSync, readFileSync, writeFileSync } from "node:fs"; -import { join, resolve } from "node:path"; - -/** - * Manage docker images: - * - build the latest image: vertesia worker build => worker_org/worker_name:latest - * - promote the latest build to a version => vertesia worker release 1.0.0 => worker_org/worker_name:1.0.0 - * - list available versions => vertesia worker versions --remote - * - publish a specific image version => vertesia worker publish 1.0.0 - */ - -export interface WorkerConfig { - profile?: string; - pm?: string; - image?: { - repository: string; - organization: string; - name: string; - } -} - -export interface WorkerPackageJson { - name: string; - version: string; - vertesia?: WorkerConfig; -} - -export class PackageJson implements WorkerPackageJson { - constructor(public file: string, public data: Record) { - if (!data.vertesia) { - data.vertesia = {}; - } - } - - get name() { - return this.data.name; - } - - set name(value: string) { - this.data.name = value; - } - - get version() { - return this.data.version; - } - - set version(value: string) { - this.data.version = value; - } - - get pm() { - return this.data.vertesia.pm; - } - - get profile() { - return this.data.vertesia.profile; - } - - set profile(value: string) { - this.data.vertesia.profile = value; - } - - getWorkerId() { - const image = this.vertesia.image; - if (!image || !image.organization || !image.name) { - console.log('Worker configuration not found or not valid in package.json'); - process.exit(1); - } - return `${image.organization}/${image.name}`; - } - - getLocalDockerTag(version: string) { - const workerId = this.getWorkerId(); - return `${workerId}:${version}`; - } - - getVertesiaDockerTag(version: string) { - const workerId = this.getWorkerId(); - let repo = this.vertesia.image.repository; - if (repo.endsWith('/')) { - repo = repo.slice(0, -1); - } - return `${repo}/workers/${workerId}:${version}`; - } - - get latestPublishedVersion() { - return this.vertesia.image.version; - } - - set latestPublishedVersion(version: string) { - this.vertesia.image.version = version; - } - - get vertesia() { - return this.data.vertesia; - } - - set vertesia(value: any) { - this.data.vertesia = value; - } - - save() { - writeFileSync(this.file, JSON.stringify(this.data, undefined, 2), 'utf8'); - } -} - -export class WorkerProject { - dir: string; - packageJsonFile: string; - _pkg?: PackageJson; - - constructor(pkgDir?: string) { - if (!pkgDir) { - pkgDir = process.cwd(); - } - pkgDir = resolve(pkgDir); - if (!existsSync(pkgDir)) { - console.log('Directory not found:', pkgDir); - process.exit(1); - } - const pkgFile = join(pkgDir, 'package.json'); - if (!existsSync(pkgFile)) { - console.log('package.json not found at', pkgFile); - process.exit(1); - } - this.dir = pkgDir; - this.packageJsonFile = pkgFile; - } - - get npmrcFile() { - return join(this.dir, '.npmrc'); - } - - get dockerConfigFile() { - return join(this.dir, 'docker.json'); - } - - get packageJson() { - if (!this._pkg) { - const pkgContent = readFileSync(this.packageJsonFile, 'utf8'); - this._pkg = new PackageJson(this.packageJsonFile, JSON.parse(pkgContent)); - } - return this._pkg; - } - - getWorkerId() { - return this.packageJson.getWorkerId(); - } - - getVertesiaDockerTag(version: string) { - return this.packageJson.getVertesiaDockerTag(version); - } - - getLocalDockerTag(version: string) { - return this.packageJson.getLocalDockerTag(version); - } - - /** - * Build the project sources using the configured package manager. - */ - buildSources() { - if (!this.packageJson.pm) { - console.error('No package manager configuration found in package.json: vertesia.pm'); - process.exit(1); - } - spawnSync(this.packageJson.pm, ['run', 'build'], { stdio: 'inherit' }); - } - -} diff --git a/packages/cli/src/worker/refresh.ts b/packages/cli/src/worker/refresh.ts deleted file mode 100644 index 163b5a3d0..000000000 --- a/packages/cli/src/worker/refresh.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { refreshProfile } from "../profiles/commands.js"; -import { config, Profile, shouldRefreshProfileToken } from "../profiles/index.js"; -import { ConfigResult } from "../profiles/server/index.js"; -import { WorkerProject } from "./project.js"; - -export function tryRefreshProjectToken(project: WorkerProject): Promise { - const profileName = project.packageJson.vertesia?.profile; - if (!profileName) { - console.error('No vertesia.profile entry found in package.json'); - process.exit(1); - } - const profile = config.getProfile(profileName); - if (!profile) { - console.error('No such profile exists: ' + profileName); - process.exit(1); - } - return tryRefreshToken(profile); -} - -export function tryRefreshToken(profile: Profile): Promise { - // Create abort controller for cancellation - const abortController = new AbortController(); - - // Set up signal handler - const handleSignal = () => { - abortController.abort(); - console.log("\nToken refresh interrupted"); - process.exit(0); - }; - - // Register signal handlers - process.on('SIGINT', handleSignal); - process.on('SIGTERM', handleSignal); - - return new Promise((resolve) => { - // If token doesn't need refresh, resolve immediately - if (!shouldRefreshProfileToken(profile, 10)) { - process.off('SIGINT', handleSignal); - process.off('SIGTERM', handleSignal); - resolve(undefined); - return; - } - - console.log("Refreshing auth token for profile:", profile.name); - - // Create a callback that cleans up signal handlers - const wrappedResolve = (result: ConfigResult | undefined) => { - process.off('SIGINT', handleSignal); - process.off('SIGTERM', handleSignal); - resolve(result); - }; - - // Start the update with our wrapped resolver - refreshProfile(profile.name, wrappedResolve, abortController.signal); - }); -} diff --git a/packages/cli/src/worker/registry.ts b/packages/cli/src/worker/registry.ts deleted file mode 100644 index 1865865ae..000000000 --- a/packages/cli/src/worker/registry.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Command } from "commander"; -import { readFileSync, writeFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { getClient } from "../client.js"; -import { config } from "../profiles/index.js"; -import { WorkerProject } from "./project.js"; - - -const REGISTRY_URI_ABS_PATH = "//us-central1-npm.pkg.dev/dengenlabs/npm/"; -function getRegistryLine() { - return `@dglabs:registry=https:${REGISTRY_URI_ABS_PATH}`; -} -function getRegistryAuthTokenLine(token: string) { - return `${REGISTRY_URI_ABS_PATH}:_authToken=${token}`; -} - -export async function getGoogleToken(program: Command, profileName?: string) { - if (profileName) { - config.use(profileName); - } - const client = await getClient(program); - console.log((await client.account.getGoogleToken()).token); -} - -export async function getGooglePrincipal(program: Command, profileName?: string) { - if (profileName) { - config.use(profileName); - } - const client = await getClient(program); - console.log((await client.account.getGoogleToken()).principal); -} - -export async function updateNpmrc(project: WorkerProject, profile: string) { - config.use(profile); - await createOrUpdateNpmRegistry(project.npmrcFile); -} - -export async function createOrUpdateNpmRegistry(npmrcFile: string) { - const client = await getClient(); - const gtok = await client.account.getGoogleToken(); - - npmrcFile = resolve(npmrcFile); - - let content = ""; - - try { - content = readFileSync(npmrcFile, "utf-8"); - } catch (err: any) { - // ignore - } - const lines = content.trim().split("\n").filter((line) => !line.includes(REGISTRY_URI_ABS_PATH)); - - lines.push(getRegistryLine()); - lines.push(getRegistryAuthTokenLine(gtok.token)); - const out = lines.join('\n') + '\n'; - - writeFileSync(npmrcFile, out, "utf8"); - -} diff --git a/packages/cli/src/worker/version.ts b/packages/cli/src/worker/version.ts deleted file mode 100644 index 56cf14326..000000000 --- a/packages/cli/src/worker/version.ts +++ /dev/null @@ -1,4 +0,0 @@ - -export function validateVersion(version: string) { - return /^([0-9]+)\.([0-9]+)\.([0-9]+)(-[a-zA-Z_0-9-]+)?$/.test(version); -} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index b434fd3ac..bd8c542ba 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -47,8 +47,6 @@ } , { "path": "../../llumiverse/common/tsconfig.json" }, - { "path": "../memory-cli/tsconfig.json" }, - { "path": "../memory-commands/tsconfig.json" }, { "path": "../workflow/tsconfig.json" } ] -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a441bf4e4..e05db91dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -197,12 +197,6 @@ importers: '@vertesia/common': specifier: workspace:* version: link:../common - '@vertesia/memory-cli': - specifier: workspace:* - version: link:../memory-cli - '@vertesia/memory-commands': - specifier: workspace:* - version: link:../memory-commands '@vertesia/workflow': specifier: workspace:* version: link:../workflow @@ -239,9 +233,6 @@ importers: gradient-string: specifier: ^3.0.0 version: 3.0.0 - json-schema-to-typescript: - specifier: ^15.0.4 - version: 15.0.4 jsonwebtoken: specifier: ^9.0.3 version: 9.0.3 @@ -285,6 +276,10 @@ importers: typescript-eslint: specifier: ^8.58.1 version: 8.58.1(eslint@10.0.2(jiti@2.6.1))(typescript@6.0.3) + optionalDependencies: + '@napi-rs/keyring': + specifier: ^1.2.0 + version: 1.2.0 packages/client: dependencies: @@ -571,65 +566,6 @@ importers: specifier: ^4.0.16 version: 4.0.18(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) - packages/memory-cli: - dependencies: - '@vertesia/memory': - specifier: workspace:* - version: link:../memory - '@vertesia/memory-commands': - specifier: workspace:* - version: link:../memory-commands - commander: - specifier: ^14.0.2 - version: 14.0.3 - devDependencies: - '@eslint/js': - specifier: ^10.0.1 - version: 10.0.1(eslint@10.0.2(jiti@2.6.1)) - '@types/node': - specifier: ^25.6.0 - version: 25.6.0 - eslint: - specifier: ^10.0.2 - version: 10.0.2(jiti@2.6.1) - typescript: - specifier: ^6.0.2 - version: 6.0.3 - typescript-eslint: - specifier: ^8.58.1 - version: 8.58.1(eslint@10.0.2(jiti@2.6.1))(typescript@6.0.3) - vitest: - specifier: ^4.0.16 - version: 4.0.18(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) - - packages/memory-commands: - dependencies: - '@vertesia/memory': - specifier: workspace:* - version: link:../memory - typescript: - specifier: ^6.0.2 - version: 6.0.3 - devDependencies: - '@eslint/js': - specifier: ^10.0.1 - version: 10.0.1(eslint@10.0.2(jiti@2.6.1)) - '@types/node': - specifier: ^25.6.0 - version: 25.6.0 - eslint: - specifier: ^10.0.2 - version: 10.0.2(jiti@2.6.1) - ts-dual-module: - specifier: ^0.6.3 - version: 0.6.3(patch_hash=fa2655a6470821a60e6e431a2f196a259c9fc67e51c0a71fc38f63017cc4213f) - typescript-eslint: - specifier: ^8.58.1 - version: 8.58.1(eslint@10.0.2(jiti@2.6.1))(typescript@6.0.3) - vitest: - specifier: ^4.0.16 - version: 4.0.18(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) - packages/plugin-builder: dependencies: '@adobe/css-tools': @@ -1415,10 +1351,6 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} - '@apidevtools/json-schema-ref-parser@11.9.3': - resolution: {integrity: sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==} - engines: {node: '>= 16'} - '@aws-crypto/crc32@5.2.0': resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} @@ -2516,9 +2448,6 @@ packages: '@js-sdsl/ordered-map@4.4.2': resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} - '@jsdevtools/ono@7.1.3': - resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} - '@jsonjoy.com/base64@1.1.2': resolution: {integrity: sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==} engines: {node: '>=10.0'} @@ -2736,6 +2665,87 @@ packages: resolution: {integrity: sha512-unVFo8CUlUeJCCxt50+j4yy91NF4x6n9zdGcvEsOFAWzowtZm3mgx8X2D7xjwV0cFSfxmpGPoe+JS77uzeFsxg==} engines: {node: '>= 10'} + '@napi-rs/keyring-darwin-arm64@1.2.0': + resolution: {integrity: sha512-CA83rDeyONDADO25JLZsh3eHY8yTEtm/RS6ecPsY+1v+dSawzT9GywBMu2r6uOp1IEhQs/xAfxgybGAFr17lSA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/keyring-darwin-x64@1.2.0': + resolution: {integrity: sha512-dBHjtKRCj4ByfnfqIKIJLo3wueQNJhLRyuxtX/rR4K/XtcS7VLlRD01XXizjpre54vpmObj63w+ZpHG+mGM8uA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/keyring-freebsd-x64@1.2.0': + resolution: {integrity: sha512-DPZFr11pNJSnaoh0dzSUNF+T6ORhy3CkzUT3uGixbA71cAOPJ24iG8e8QrLOkuC/StWrAku3gBnth2XMWOcR3Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@napi-rs/keyring-linux-arm-gnueabihf@1.2.0': + resolution: {integrity: sha512-8xv6DyEMlvRdqJzp4F39RLUmmTQsLcGYYv/3eIfZNZN1O5257tHxTrFYqAsny659rJJK2EKeSa7PhrSibQqRWQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/keyring-linux-arm64-gnu@1.2.0': + resolution: {integrity: sha512-Pu2V6Py+PBt7inryEecirl+t+ti8bhZphjP+W68iVaXHUxLdWmkgL9KI1VkbRHbx5k8K5Tew9OP218YfmVguIA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/keyring-linux-arm64-musl@1.2.0': + resolution: {integrity: sha512-8TDymrpC4P1a9iDEaegT7RnrkmrJN5eNZh3Im3UEV5PPYGtrb82CRxsuFohthCWQW81O483u1bu+25+XA4nKUw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/keyring-linux-riscv64-gnu@1.2.0': + resolution: {integrity: sha512-awsB5XI1MYL7fwfjMDGmKOWvNgJEO7mM7iVEMS0fO39f0kVJnOSjlu7RHcXAF0LOx+0VfF3oxbWqJmZbvRCRHw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@napi-rs/keyring-linux-x64-gnu@1.2.0': + resolution: {integrity: sha512-8E+7z4tbxSJXxIBqA+vfB1CGajpCDRyTyqXkBig5NtASrv4YXcntSo96Iah2QDR5zD3dSTsmbqJudcj9rKKuHQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/keyring-linux-x64-musl@1.2.0': + resolution: {integrity: sha512-8RZ8yVEnmWr/3BxKgBSzmgntI7lNEsY7xouNfOsQkuVAiCNmxzJwETspzK3PQ2FHtDxgz5vHQDEBVGMyM4hUHA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/keyring-win32-arm64-msvc@1.2.0': + resolution: {integrity: sha512-AoqaDZpQ6KPE19VBLpxyORcp+yWmHI9Xs9Oo0PJ4mfHma4nFSLVdhAubJCxdlNptHe5va7ghGCHj3L9Akiv4cQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/keyring-win32-ia32-msvc@1.2.0': + resolution: {integrity: sha512-EYL+EEI6bCsYi3LfwcQdnX3P/R76ENKNn+3PmpGheBsUFLuh0gQuP7aMVHM4rTw6UVe+L3vCLZSptq/oeacz0A==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@napi-rs/keyring-win32-x64-msvc@1.2.0': + resolution: {integrity: sha512-xFlx/TsmqmCwNU9v+AVnEJgoEAlBYgzFF5Ihz1rMpPAt4qQWWkMd4sCyM1gMJ1A/GnRqRegDiQpwaxGUHFtFbA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/keyring@1.2.0': + resolution: {integrity: sha512-d0d4Oyxm+v980PEq1ZH2PmS6cvpMIRc17eYpiU47KgW+lzxklMu6+HOEOPmxrpnF/XQZ0+Q78I2mgMhbIIo/dg==} + engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -6168,11 +6178,6 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} - json-schema-to-typescript@15.0.4: - resolution: {integrity: sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==} - engines: {node: '>=16.0.0'} - hasBin: true - json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -6378,9 +6383,6 @@ packages: lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} - lodash@4.18.1: - resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} - log-symbols@4.1.0: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} @@ -6939,11 +6941,6 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - prettier@3.8.1: - resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} - engines: {node: '>=14'} - hasBin: true - pretty-ms@9.3.0: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} @@ -8211,12 +8208,6 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.0.2 - '@apidevtools/json-schema-ref-parser@11.9.3': - dependencies: - '@jsdevtools/ono': 7.1.3 - '@types/json-schema': 7.0.15 - js-yaml: 4.1.1 - '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 @@ -9827,8 +9818,6 @@ snapshots: '@js-sdsl/ordered-map@4.4.2': {} - '@jsdevtools/ono@7.1.3': {} - '@jsonjoy.com/base64@1.1.2(tslib@2.8.1)': dependencies: tslib: 2.8.1 @@ -10044,6 +10033,58 @@ snapshots: '@napi-rs/canvas-win32-x64-msvc': 0.1.93 optional: true + '@napi-rs/keyring-darwin-arm64@1.2.0': + optional: true + + '@napi-rs/keyring-darwin-x64@1.2.0': + optional: true + + '@napi-rs/keyring-freebsd-x64@1.2.0': + optional: true + + '@napi-rs/keyring-linux-arm-gnueabihf@1.2.0': + optional: true + + '@napi-rs/keyring-linux-arm64-gnu@1.2.0': + optional: true + + '@napi-rs/keyring-linux-arm64-musl@1.2.0': + optional: true + + '@napi-rs/keyring-linux-riscv64-gnu@1.2.0': + optional: true + + '@napi-rs/keyring-linux-x64-gnu@1.2.0': + optional: true + + '@napi-rs/keyring-linux-x64-musl@1.2.0': + optional: true + + '@napi-rs/keyring-win32-arm64-msvc@1.2.0': + optional: true + + '@napi-rs/keyring-win32-ia32-msvc@1.2.0': + optional: true + + '@napi-rs/keyring-win32-x64-msvc@1.2.0': + optional: true + + '@napi-rs/keyring@1.2.0': + optionalDependencies: + '@napi-rs/keyring-darwin-arm64': 1.2.0 + '@napi-rs/keyring-darwin-x64': 1.2.0 + '@napi-rs/keyring-freebsd-x64': 1.2.0 + '@napi-rs/keyring-linux-arm-gnueabihf': 1.2.0 + '@napi-rs/keyring-linux-arm64-gnu': 1.2.0 + '@napi-rs/keyring-linux-arm64-musl': 1.2.0 + '@napi-rs/keyring-linux-riscv64-gnu': 1.2.0 + '@napi-rs/keyring-linux-x64-gnu': 1.2.0 + '@napi-rs/keyring-linux-x64-musl': 1.2.0 + '@napi-rs/keyring-win32-arm64-msvc': 1.2.0 + '@napi-rs/keyring-win32-ia32-msvc': 1.2.0 + '@napi-rs/keyring-win32-x64-msvc': 1.2.0 + optional: true + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.8.1 @@ -13939,18 +13980,6 @@ snapshots: json-parse-even-better-errors@2.3.1: {} - json-schema-to-typescript@15.0.4: - dependencies: - '@apidevtools/json-schema-ref-parser': 11.9.3 - '@types/json-schema': 7.0.15 - '@types/lodash': 4.17.23 - is-glob: 4.0.3 - js-yaml: 4.1.1 - lodash: 4.18.1 - minimist: 1.2.8 - prettier: 3.8.1 - tinyglobby: 0.2.15 - json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} @@ -14153,8 +14182,6 @@ snapshots: lodash.once@4.1.1: {} - lodash@4.18.1: {} - log-symbols@4.1.0: dependencies: chalk: 4.1.2 @@ -15007,8 +15034,6 @@ snapshots: prelude-ls@1.2.1: {} - prettier@3.8.1: {} - pretty-ms@9.3.0: dependencies: parse-ms: 4.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d964f2ff4..7cd70c0b3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,8 @@ packages: - llumiverse/common - packages/* + - "!packages/memory-cli" + - "!packages/memory-commands" - templates/* - "!templates/worker-template" minimumReleaseAge: 4320 # 3 days in minutes, minimumReleaseAge protects against supply chain attacks. From 47395a1adf861f0e118fee67e7bf7b47fbac9f9d Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Fri, 24 Apr 2026 20:00:00 +0900 Subject: [PATCH 17/75] feat: add support for development deployment targets in config URL and server URL functions --- packages/cli/src/profiles/index.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/cli/src/profiles/index.ts b/packages/cli/src/profiles/index.ts index f9bc21c03..de7fe1300 100644 --- a/packages/cli/src/profiles/index.ts +++ b/packages/cli/src/profiles/index.ts @@ -23,6 +23,9 @@ export const AVAILABLE_REGIONS: Region[] = ['us1', 'eu1', 'jp1']; export type ConfigUrlRef = "local" | "dev-main" | "dev-preview" | "preview" | "prod" | string; export function getConfigUrl(value: ConfigUrlRef, region: Region = DEFAULT_REGION): string { + if (isDevDeploymentTarget(value)) { + return `https://${value}.ui.dev1.vertesia.io/cli`; + } switch (value) { case "local": return "https://localhost:5173/cli"; @@ -43,6 +46,12 @@ export function getConfigUrl(value: ConfigUrlRef, region: Region = DEFAULT_REGIO } } export function getServerUrls(value: ConfigUrlRef, region: Region = DEFAULT_REGION): { studio_server_url: string; zeno_server_url: string } { + if (isDevDeploymentTarget(value)) { + return { + studio_server_url: `https://studio-server-${value}.api.dev1.vertesia.io`, + zeno_server_url: `https://zeno-server-${value}.api.dev1.vertesia.io`, + }; + } switch (value) { case "local": return { @@ -73,6 +82,11 @@ export function getServerUrls(value: ConfigUrlRef, region: Region = DEFAULT_REGI throw new Error("Unable to detect server urls from custom target."); } } + +function isDevDeploymentTarget(value: string): boolean { + return value.startsWith('dev-'); +} + export function getCloudTypeFromConfigUrl(url: string) { if (url.startsWith("https://localhost")) { return "staging"; From 496b28713b4b4108d88d23e9ba1e1658eef6c797 Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Fri, 24 Apr 2026 23:58:00 +0900 Subject: [PATCH 18/75] feat: implement worker commands and docker credential management --- .../cli/bin/docker-credential-vertesia.js | 3 + packages/cli/package.json | 5 +- packages/cli/src/index.ts | 13 ++ packages/cli/src/memory/index.ts | 22 +++ packages/cli/src/worker/commands.ts | 179 ++++++++++++++++++ packages/cli/src/worker/connect.ts | 72 +++++++ packages/cli/src/worker/docker-credential.ts | 50 +++++ packages/cli/src/worker/docker.ts | 93 +++++++++ packages/cli/src/worker/index.ts | 110 +++++++++++ packages/cli/src/worker/project.ts | 155 +++++++++++++++ packages/cli/src/worker/refresh.ts | 50 +++++ packages/cli/src/worker/registry.ts | 59 ++++++ packages/cli/src/worker/version.ts | 3 + pnpm-lock.yaml | 65 +++++++ pnpm-workspace.yaml | 2 - 15 files changed, 878 insertions(+), 3 deletions(-) create mode 100755 packages/cli/bin/docker-credential-vertesia.js create mode 100644 packages/cli/src/memory/index.ts create mode 100644 packages/cli/src/worker/commands.ts create mode 100644 packages/cli/src/worker/connect.ts create mode 100644 packages/cli/src/worker/docker-credential.ts create mode 100644 packages/cli/src/worker/docker.ts create mode 100644 packages/cli/src/worker/index.ts create mode 100644 packages/cli/src/worker/project.ts create mode 100644 packages/cli/src/worker/refresh.ts create mode 100644 packages/cli/src/worker/registry.ts create mode 100644 packages/cli/src/worker/version.ts diff --git a/packages/cli/bin/docker-credential-vertesia.js b/packages/cli/bin/docker-credential-vertesia.js new file mode 100755 index 000000000..9713f4f56 --- /dev/null +++ b/packages/cli/bin/docker-credential-vertesia.js @@ -0,0 +1,3 @@ +#!/usr/bin/env node + +import "../lib/worker/docker-credential.js"; diff --git a/packages/cli/package.json b/packages/cli/package.json index 9738db3fa..86fb5cfc1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -4,7 +4,8 @@ "description": "The Vertesia command-line interface (CLI) provides a set of commands to manage and interact with the Vertesia Platform.", "type": "module", "bin": { - "vertesia": "./bin/app.js" + "vertesia": "./bin/app.js", + "docker-credential-vertesia": "./bin/docker-credential-vertesia.js" }, "main": "./lib/index.js", "types": "./lib/index.d.ts", @@ -34,6 +35,8 @@ "@llumiverse/common": "workspace:*", "@vertesia/client": "workspace:*", "@vertesia/common": "workspace:*", + "@vertesia/memory-cli": "workspace:*", + "@vertesia/memory-commands": "workspace:*", "@vertesia/workflow": "workspace:*", "ansi-colors": "^4.1.3", "ansi-escapes": "^6.2.0", diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index a024e698d..1a7ce0f18 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,8 +1,10 @@ +import { setupMemoCommand } from '@vertesia/memory-cli'; import { Command } from 'commander'; import { registerAppsCommand } from './apps/index.js'; import { registerAgentsCommand } from './agents/index.js'; import { listEnvironments } from './envs/index.js'; import { listInteractions } from './interactions/index.js'; +import { getPublishMemoryAction } from './memory/index.js'; import { registerObjectsCommand } from './objects/index.js'; import { getVersion, upgrade } from './package.js'; import { createProfile, deleteProfile, listProfiles, showActiveAuthToken, showProfile, tryRefreshToken, updateCurrentProfile, updateProfile, useProfile, type CreateProfileOptions } from './profiles/commands.js'; @@ -10,6 +12,7 @@ import { AVAILABLE_REGIONS, DEFAULT_REGION, getConfigFile } from './profiles/ind import { listProjects } from './projects/index.js'; import runInteraction from './run/index.js'; import { runHistory } from './runs/index.js'; +import { registerWorkerCommand } from './worker/index.js'; import { registerWorkflowsCommand } from './workflows/index.js'; //warnIfNotLatest(); @@ -90,6 +93,16 @@ program.command("runs [interactionId]") runHistory(program, interactionId, options); }); +const memoCmd = program.command("memo") + .description("Build and export memory packs (deprecated)"); +setupMemoCommand(memoCmd, getPublishMemoryAction(program)); +for (const command of memoCmd.commands) { + command.hook('preAction', () => { + console.warn('Warning: `vertesia memo` is deprecated and will be removed in a future release.'); + }); +} + +registerWorkerCommand(program); registerAppsCommand(program); registerAgentsCommand(program); diff --git a/packages/cli/src/memory/index.ts b/packages/cli/src/memory/index.ts new file mode 100644 index 000000000..8d5a8bffc --- /dev/null +++ b/packages/cli/src/memory/index.ts @@ -0,0 +1,22 @@ +import { VertesiaClient } from "@vertesia/client"; +import { NodeStreamSource } from "@vertesia/client/node"; +import { Command } from "commander"; +import { createReadStream } from "fs"; +import { getClient } from "../client.js"; + +export function getPublishMemoryAction(program: Command) { + return async (file: string, name: string) => { + const client = await getClient(program); + return publishMemory(client, file, name); + }; +} + +async function publishMemory(client: VertesiaClient, file: string, name: string) { + const stream = createReadStream(file); + const path = await client.files.uploadMemoryPack(new NodeStreamSource( + stream, + name, + "application/gzip", + )); + return path; +} diff --git a/packages/cli/src/worker/commands.ts b/packages/cli/src/worker/commands.ts new file mode 100644 index 000000000..ca4389481 --- /dev/null +++ b/packages/cli/src/worker/commands.ts @@ -0,0 +1,179 @@ +import colors from "ansi-colors"; +import fs from "fs"; +import os from "os"; +import { join } from "path"; +import { getClient } from "../client.js"; +import { config, getCloudTypeFromConfigUrl, Profile } from "../profiles/index.js"; +import { runDocker, runDockerWithOutput, runDockerWithWorkerConfig } from "./docker.js"; +import { WorkerProject } from "./project.js"; +import { tryRefreshProjectToken } from "./refresh.js"; +import { updateNpmrc } from "./registry.js"; +import { validateVersion } from "./version.js"; + +export enum PublishMode { + Push = 1, + Deploy = 2, + PushAndDeploy = 3, +} + +function shouldDeploy(mode: PublishMode) { + return mode & PublishMode.Deploy; +} + +function shouldPush(mode: PublishMode) { + return mode & PublishMode.Push; +} + +const TARGET_PLATFORM = "linux/amd64"; +const LATEST_VERSION = "latest"; + +async function pushImage(project: WorkerProject, version: string) { + await tryRefreshProjectToken(project); + + const localTag = project.getLocalDockerTag(version); + const remoteTag = project.getVertesiaDockerTag(version); + + console.log(`Pushing docker image ${remoteTag}`); + runDocker(["tag", localTag, remoteTag]); + runDockerWithWorkerConfig(["push", remoteTag]); +} + +async function triggerDeploy(profile: Profile, project: WorkerProject, version: string) { + const environment = getCloudTypeFromConfigUrl(profile.config_url); + const client = await getClient(); + console.log(`Deploy worker ${project.getWorkerId()}:${version} to ${environment}`); + await client.store.workers.deploy({ + environment, + workerId: project.getWorkerId(), + version, + }); +} + +export async function publish(version: string, mode: PublishMode) { + if (!validateVersion(version)) { + console.log(`Invalid version format: ${version}. Use major.minor.patch[-modifier] format.`); + process.exit(1); + } + if (!config.current) { + console.log("No active profile is defined."); + process.exit(1); + } + const profile = config.current; + const project = new WorkerProject(); + if (shouldPush(mode)) { + await pushImage(project, version); + } + if (shouldDeploy(mode)) { + await triggerDeploy(profile, project, version); + } +} + +export async function build(contextDir = ".") { + const project = new WorkerProject(); + + const refreshResult = await tryRefreshProjectToken(project); + if (refreshResult) { + await updateNpmrc(project, refreshResult.profile); + } + + const tag = project.getLocalDockerTag(LATEST_VERSION); + const args = ["buildx", "build", "--platform", TARGET_PLATFORM, "-t", tag]; + if (contextDir !== ".") { + args.push("-f", "Dockerfile"); + } + console.log(`Building docker image: ${tag}`); + runDocker([...args, contextDir]); +} + +export async function release(version: string) { + if (!validateVersion(version)) { + console.log(`Invalid version format: ${version}. Use major.minor.patch[-modifier] format.`); + process.exit(1); + } + const project = new WorkerProject(); + const latestTag = project.getLocalDockerTag(LATEST_VERSION); + const versionTag = project.getLocalDockerTag(version); + + runDocker(["tag", latestTag, versionTag]); +} + +export function run(version: string = LATEST_VERSION) { + if (version !== LATEST_VERSION && !validateVersion(version)) { + console.log(`Invalid version format: ${version}. Use major.minor.patch[-modifier] format.`); + process.exit(1); + } + const project = new WorkerProject(); + const tag = project.getLocalDockerTag(version); + const args = ["run", "--platform", TARGET_PLATFORM, "--env-file", ".env"]; + const googleCredsFile = getGoogleCredentialsFile(); + if (googleCredsFile) { + args.push("-v", `${googleCredsFile}:/tmp/google-credentials.json`); + args.push("-e", "GOOGLE_APPLICATION_CREDENTIALS=/tmp/google-credentials.json"); + } + args.push(tag); + runDocker(args); +} + +export async function listVersions() { + const project = new WorkerProject(); + if (!project.packageJson.vertesia?.image) { + console.error("Invalid package.json. Missing vertesia.image configuration."); + process.exit(1); + } + const out = runDockerWithOutput(["images", "--format", "{{.Repository}}:{{.Tag}}"]); + const lines = out.trim().split("\n"); + const localTagPrefix = project.getLocalDockerTag(""); + const remoteTagPrefix = project.getVertesiaDockerTag(""); + const localTags: Record = {}; + const remoteTags: Record = {}; + const versions = new Set(); + for (const line of lines) { + const index = line.indexOf(":"); + if (index > 0) { + const name = line.substring(0, index); + const version = line.substring(index + 1); + if (line.startsWith(localTagPrefix)) { + localTags[version] = { version, name }; + versions.add(version); + } else if (line.startsWith(remoteTagPrefix)) { + remoteTags[version] = { version, name }; + versions.add(version); + } + } + } + + versions.delete(LATEST_VERSION); + printVersion(LATEST_VERSION, localTags[LATEST_VERSION], remoteTags[LATEST_VERSION]); + Array.from(versions).sort((left, right) => right.localeCompare(left)).forEach((version) => { + printVersion(version, localTags[version], remoteTags[version]); + }); +} + +function printVersion(version: string, local: TagInfo | undefined, remote: TagInfo | undefined) { + if (!local && !remote) { + return; + } + const isLatest = version === LATEST_VERSION; + console.log(colors.bold(isLatest ? version : `v${version}`)); + console.log(`\t${colors.green("Local tag:")} ${local ? `${local.name}:${version}` : "N/A"}`); + if (remote) { + console.log(`\t${colors.green("published to:")} ${remote.name}:${version}`); + } else if (isLatest) { + console.log(colors.dim("\tcannot be published")); + } else { + console.log(colors.dim("\tnot published")); + } +} + +interface TagInfo { + version: string; + name: string; +} + +function getGoogleCredentialsFile() { + const file = join(os.homedir(), ".config/gcloud/application_default_credentials.json"); + if (fs.existsSync(file)) { + return file; + } + return null; +} diff --git a/packages/cli/src/worker/connect.ts b/packages/cli/src/worker/connect.ts new file mode 100644 index 000000000..4d16a0699 --- /dev/null +++ b/packages/cli/src/worker/connect.ts @@ -0,0 +1,72 @@ +import enquirer from "enquirer"; +import { createProfile, refreshProfile } from "../profiles/commands.js"; +import { config, shouldRefreshProfileToken } from "../profiles/index.js"; +import { ConfigResult } from "../profiles/server/index.js"; +import { WorkerProject } from "./project.js"; +import { updateNpmrc } from "./registry.js"; + +const { prompt } = enquirer; + +interface ConnectOptions { + nonInteractive?: boolean; + profile?: string; +} + +export async function connectToProject(options: ConnectOptions) { + const allowInteraction = !options.nonInteractive; + const project = new WorkerProject(); + const pkg = project.packageJson; + let profileName: string | undefined = options.profile || pkg.vertesia.profile; + const onAuthenticationDone = async (result: ConfigResult | undefined) => { + if (result) { + await updateNpmrc(project, result.profile); + } + }; + try { + if (allowInteraction && !profileName) { + profileName = await askProfileName(); + if (!profileName) { + profileName = await createProfile(undefined, { onResult: onAuthenticationDone }); + } + } + if (!profileName) { + console.log("Profile not specified. When using --non-interactive mode you may want to specify a profile"); + process.exit(1); + } + const profile = config.getProfile(profileName); + if (!profile) { + console.log("Profile not found:", profileName); + process.exit(1); + } + if (allowInteraction && shouldRefreshProfileToken(profile, 10)) { + console.log("Refreshing auth token for profile:", profileName); + await refreshProfile(profileName, onAuthenticationDone); + } else { + await updateNpmrc(project, profileName); + } + } finally { + if (pkg.vertesia.profile !== profileName) { + pkg.vertesia.profile = profileName; + pkg.save(); + } + } +} + +async function askProfileName() { + const profiles = config.profiles.map((profile) => profile.name); + if (profiles.length > 0) { + const newProfile = "Create new profile"; + profiles.push(newProfile); + const answer: Record = await prompt({ + name: "profile", + type: "select", + message: "Select a profile to use when connecting.", + initial: profiles.length - 1, + choices: profiles, + }); + if (newProfile !== answer.profile) { + return answer.profile; + } + } + return undefined; +} diff --git a/packages/cli/src/worker/docker-credential.ts b/packages/cli/src/worker/docker-credential.ts new file mode 100644 index 000000000..dd44b4aba --- /dev/null +++ b/packages/cli/src/worker/docker-credential.ts @@ -0,0 +1,50 @@ +import fs from "node:fs"; +import { getDockerCredentials } from "./docker.js"; + +function handleNoOp() { + process.exit(0); +} + +async function handleGet(serverUrl: string) { + if (!serverUrl.endsWith("-docker.pkg.dev")) { + process.exit(0); + } + try { + const credentials = await getDockerCredentials(serverUrl); + if (process.env.DEBUG_DOCKER_CREDS) { + fs.writeFileSync("./docker-creds-helper.log", `Get token for registry ${serverUrl} => ${JSON.stringify(credentials, null, 2)}`, "utf8"); + } + process.stdout.write(JSON.stringify(credentials)); + } catch (error: any) { + fs.writeFileSync("./docker-creds-helper-error.log", `Get token for registry ${serverUrl} => ${JSON.stringify(error, null, 2)}`, "utf8"); + console.error("Error fetching credentials:", error.message); + process.exit(1); + } +} + +function main() { + const command = process.argv[2]; + + if (!command) { + console.error("No command provided"); + process.exit(1); + } + + switch (command) { + case "get": { + const stdin = fs.readFileSync(0, "utf-8"); + void handleGet(stdin.trim()); + break; + } + case "store": + case "erase": + case "list": + handleNoOp(); + break; + default: + console.error(`Unknown command: ${command}`); + process.exit(1); + } +} + +main(); diff --git a/packages/cli/src/worker/docker.ts b/packages/cli/src/worker/docker.ts new file mode 100644 index 000000000..4aa26f3db --- /dev/null +++ b/packages/cli/src/worker/docker.ts @@ -0,0 +1,93 @@ +import ansiColors from "ansi-colors"; +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { getClient } from "../client.js"; +import { config } from "../profiles/index.js"; +import { WorkerProject } from "./project.js"; + +const LOCAL_DOCKER_CONFIG_DIR = ".docker"; + +export function generateDockerConfig() { + return JSON.stringify({ + credHelpers: { + "us-central1-docker.pkg.dev": "vertesia", + }, + }, undefined, 2); +} + +async function getGoogleToken(pkgDir?: string) { + const project = new WorkerProject(pkgDir); + const pkg = project.packageJson; + if (!pkg.vertesia.profile) { + throw new Error("Profile entry not found in package.json"); + } + config.use(pkg.vertesia.profile); + const client = await getClient(); + const result = await client.account.getGoogleToken(); + return result.token; +} + +export async function getDockerCredentials(serverUrl: string) { + const token = await getGoogleToken(); + return { + ServerURL: serverUrl, + Username: "oauth2accesstoken", + Secret: token, + }; +} + +export function runDockerWithWorkerConfig(args: string[]) { + const configDir = join(process.cwd(), LOCAL_DOCKER_CONFIG_DIR); + const baseArgs: string[] = []; + if (existsSync(configDir)) { + baseArgs.push("--config", configDir); + } + return runDocker(baseArgs.concat(args)); +} + +export function runDocker(args: string[]) { + const verbose = process.argv.includes("--verbose"); + if (verbose) { + console.log(`Running: ${ansiColors.magenta(`docker ${args.join(" ")}`)}`); + } + const result = spawnSync("docker", args, { + stdio: "inherit", + env: { + ...process.env, + DOCKER_BUILDKIT: "1", + }, + }); + if (result.error) { + console.error(`Failed to execute command "docker ${args.join(" ")}":`, result.error); + process.exit(2); + } + if (result.status !== 0) { + console.error(`Command "docker ${args.join(" ")}" failed with exit code ${result.status}`); + process.exit(result.status ?? 1); + } + return result; +} + +export function runDockerWithOutput(args: string[]) { + const verbose = process.argv.includes("--verbose"); + if (verbose) { + console.log(`Running: ${ansiColors.magenta(`docker ${args.join(" ")}`)}`); + } + const result = spawnSync("docker", args, { + encoding: "utf-8", + env: { + ...process.env, + DOCKER_BUILDKIT: "1", + }, + }); + if (result.error) { + console.error(`Failed to execute command "docker ${args.join(" ")}":`, result.error); + process.exit(2); + } + if (result.status !== 0) { + console.error(`Command "docker ${args.join(" ")}" failed with exit code ${result.status}\n${result.stderr}`); + process.exit(result.status ?? 1); + } + return result.stdout.trim(); +} diff --git a/packages/cli/src/worker/index.ts b/packages/cli/src/worker/index.ts new file mode 100644 index 000000000..632151207 --- /dev/null +++ b/packages/cli/src/worker/index.ts @@ -0,0 +1,110 @@ +import { Command } from "commander"; +import { build, listVersions, publish, PublishMode, release, run } from "./commands.js"; +import { connectToProject } from "./connect.js"; +import { getGooglePrincipal, getGoogleToken } from "./registry.js"; + +const DEPRECATION_MESSAGE = "Warning: `vertesia worker` is deprecated and will be removed in a future release."; + +function warnDeprecatedWorkerCommand() { + console.warn(DEPRECATION_MESSAGE); +} + +export function registerWorkerCommand(program: Command) { + const worker = program.command("worker") + .description("Build, release, and deploy custom workflow workers (deprecated)"); + + worker.command("connect [pkgDir]") + .description("Connect a node package to a Vertesia project. If no packageDir is specified the current dir will be used.") + .option("-I, --non-interactive", "Don't do interactions with the user. Assume the user is already authenticated.") + .option("-p, --profile [profile]", "The profile name to use. If not specified the one from the package.json will be used.") + .action(async (pkgDir: string, options: Record = {}) => { + warnDeprecatedWorkerCommand(); + if (pkgDir) { + process.chdir(pkgDir); + } + await connectToProject(options); + }); + + worker.command("publish ") + .description("Deploy a custom workflow worker. The user will be asked for a target image version.") + .option("-d, --dir [project_dir]", "Use this as the current directory.") + .option("--push-only", "If used the docker image will be push only. The deployment will not be triggered.") + .option("--deploy-only", "If used the docker is assumed to be already pushed and only the deploy will be triggered.") + .option("--verbose", "Print more information.") + .action(async (version: string, options: Record = {}) => { + warnDeprecatedWorkerCommand(); + if (options.dir) { + process.chdir(options.dir); + } + let mode: PublishMode; + if (options.pushOnly) { + mode = PublishMode.Push; + } else if (options.deployOnly) { + mode = PublishMode.Deploy; + } else { + mode = PublishMode.PushAndDeploy; + } + await publish(version, mode); + }); + + worker.command("build") + .description("Build a local docker image using 'latest' as version.") + .option("-d, --dir [project_dir]", "Use this as the current directory.") + .option("-c, --context [context]", "The docker build context to use. Defaults to the project directory.") + .option("--verbose", "Print more information.") + .action(async (options: Record) => { + warnDeprecatedWorkerCommand(); + if (options.dir) { + process.chdir(options.dir); + } + await build(options.context); + }); + + worker.command("release [version]") + .description("Promote the latest version to a named version (tag it).") + .option("-d, --dir [project_dir]", "Use this as the current directory.") + .option("--verbose", "Print more information.") + .action(async (version: string, options: Record) => { + warnDeprecatedWorkerCommand(); + if (options.dir) { + process.chdir(options.dir); + } + await release(version); + }); + + worker.command("run [version]") + .description("Run the docker image identified by the given version or the 'latest' version if no version is given.") + .option("-d, --dir [project_dir]", "Use this as the current directory.") + .option("--verbose", "Print more information.") + .action(async (version: string, options: Record) => { + warnDeprecatedWorkerCommand(); + if (options.dir) { + process.chdir(options.dir); + } + await run(version); + }); + + worker.command("versions") + .description("List existing versions.") + .option("--verbose", "Print more information.") + .action(async () => { + warnDeprecatedWorkerCommand(); + await listVersions(); + }); + + worker.command("gtoken") + .description("Get a google cloud token for the current vertesia project.") + .option("-p, --profile", "The profile name to use. If specified it will be used instead of the current profile.") + .action(async (options: Record = {}) => { + warnDeprecatedWorkerCommand(); + await getGoogleToken(program, options.profile); + }); + + worker.command("gprincipal") + .description("Get the google cloud principal for the current project.") + .option("-p, --profile", "The profile name to use. If specified it will be used instead of the current profile.") + .action(async (options: Record = {}) => { + warnDeprecatedWorkerCommand(); + await getGooglePrincipal(program, options.profile); + }); +} diff --git a/packages/cli/src/worker/project.ts b/packages/cli/src/worker/project.ts new file mode 100644 index 000000000..9c7ccdb01 --- /dev/null +++ b/packages/cli/src/worker/project.ts @@ -0,0 +1,155 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; + +export interface WorkerConfig { + profile?: string; + pm?: string; + image?: { + repository: string; + organization: string; + name: string; + version?: string; + }; +} + +export interface WorkerPackageJson { + name: string; + version: string; + vertesia?: WorkerConfig; +} + +export class PackageJson implements WorkerPackageJson { + constructor(public file: string, public data: Record) { + if (!data.vertesia) { + data.vertesia = {}; + } + } + + get name() { + return this.data.name; + } + + set name(value: string) { + this.data.name = value; + } + + get version() { + return this.data.version; + } + + set version(value: string) { + this.data.version = value; + } + + get pm() { + return this.data.vertesia.pm; + } + + get profile() { + return this.data.vertesia.profile; + } + + set profile(value: string) { + this.data.vertesia.profile = value; + } + + getWorkerId() { + const image = this.vertesia.image; + if (!image || !image.organization || !image.name) { + console.log("Worker configuration not found or not valid in package.json"); + process.exit(1); + } + return `${image.organization}/${image.name}`; + } + + getLocalDockerTag(version: string) { + return `${this.getWorkerId()}:${version}`; + } + + getVertesiaDockerTag(version: string) { + const workerId = this.getWorkerId(); + let repo = this.vertesia.image.repository; + if (repo.endsWith("/")) { + repo = repo.slice(0, -1); + } + return `${repo}/workers/${workerId}:${version}`; + } + + get latestPublishedVersion() { + return this.vertesia.image.version; + } + + set latestPublishedVersion(version: string) { + this.vertesia.image.version = version; + } + + get vertesia() { + return this.data.vertesia; + } + + set vertesia(value: any) { + this.data.vertesia = value; + } + + save() { + writeFileSync(this.file, JSON.stringify(this.data, undefined, 2), "utf8"); + } +} + +export class WorkerProject { + dir: string; + packageJsonFile: string; + _pkg?: PackageJson; + + constructor(pkgDir?: string) { + const resolvedDir = resolve(pkgDir || process.cwd()); + if (!existsSync(resolvedDir)) { + console.log("Directory not found:", resolvedDir); + process.exit(1); + } + const pkgFile = join(resolvedDir, "package.json"); + if (!existsSync(pkgFile)) { + console.log("package.json not found at", pkgFile); + process.exit(1); + } + this.dir = resolvedDir; + this.packageJsonFile = pkgFile; + } + + get npmrcFile() { + return join(this.dir, ".npmrc"); + } + + get dockerConfigFile() { + return join(this.dir, "docker.json"); + } + + get packageJson() { + if (!this._pkg) { + const pkgContent = readFileSync(this.packageJsonFile, "utf8"); + this._pkg = new PackageJson(this.packageJsonFile, JSON.parse(pkgContent)); + } + return this._pkg; + } + + getWorkerId() { + return this.packageJson.getWorkerId(); + } + + getVertesiaDockerTag(version: string) { + return this.packageJson.getVertesiaDockerTag(version); + } + + getLocalDockerTag(version: string) { + return this.packageJson.getLocalDockerTag(version); + } + + buildSources() { + if (!this.packageJson.pm) { + console.error("No package manager configuration found in package.json: vertesia.pm"); + process.exit(1); + } + spawnSync(this.packageJson.pm, ["run", "build"], { stdio: "inherit" }); + } +} diff --git a/packages/cli/src/worker/refresh.ts b/packages/cli/src/worker/refresh.ts new file mode 100644 index 000000000..3796defe1 --- /dev/null +++ b/packages/cli/src/worker/refresh.ts @@ -0,0 +1,50 @@ +import { refreshProfile } from "../profiles/commands.js"; +import { config, Profile, shouldRefreshProfileToken } from "../profiles/index.js"; +import { ConfigResult } from "../profiles/server/index.js"; +import { WorkerProject } from "./project.js"; + +export function tryRefreshProjectToken(project: WorkerProject): Promise { + const profileName = project.packageJson.vertesia?.profile; + if (!profileName) { + console.error("No vertesia.profile entry found in package.json"); + process.exit(1); + } + const profile = config.getProfile(profileName); + if (!profile) { + console.error(`No such profile exists: ${profileName}`); + process.exit(1); + } + return tryRefreshToken(profile); +} + +export function tryRefreshToken(profile: Profile): Promise { + const abortController = new AbortController(); + + const handleSignal = () => { + abortController.abort(); + console.log("\nToken refresh interrupted"); + process.exit(0); + }; + + process.on("SIGINT", handleSignal); + process.on("SIGTERM", handleSignal); + + return new Promise((resolve) => { + if (!shouldRefreshProfileToken(profile, 10)) { + process.off("SIGINT", handleSignal); + process.off("SIGTERM", handleSignal); + resolve(undefined); + return; + } + + console.log("Refreshing auth token for profile:", profile.name); + + const wrappedResolve = (result: ConfigResult | undefined) => { + process.off("SIGINT", handleSignal); + process.off("SIGTERM", handleSignal); + resolve(result); + }; + + refreshProfile(profile.name, wrappedResolve, abortController.signal); + }); +} diff --git a/packages/cli/src/worker/registry.ts b/packages/cli/src/worker/registry.ts new file mode 100644 index 000000000..c7d0d58da --- /dev/null +++ b/packages/cli/src/worker/registry.ts @@ -0,0 +1,59 @@ +import { Command } from "commander"; +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { getClient } from "../client.js"; +import { config } from "../profiles/index.js"; +import { WorkerProject } from "./project.js"; + +const REGISTRY_URI_ABS_PATH = "//us-central1-npm.pkg.dev/dengenlabs/npm/"; + +function getRegistryLine() { + return `@dglabs:registry=https:${REGISTRY_URI_ABS_PATH}`; +} + +function getRegistryAuthTokenLine(token: string) { + return `${REGISTRY_URI_ABS_PATH}:_authToken=${token}`; +} + +export async function getGoogleToken(program: Command, profileName?: string) { + if (profileName) { + config.use(profileName); + } + const client = await getClient(program); + console.log((await client.account.getGoogleToken()).token); +} + +export async function getGooglePrincipal(program: Command, profileName?: string) { + if (profileName) { + config.use(profileName); + } + const client = await getClient(program); + console.log((await client.account.getGoogleToken()).principal); +} + +export async function updateNpmrc(project: WorkerProject, profile: string) { + config.use(profile); + await createOrUpdateNpmRegistry(project.npmrcFile); +} + +export async function createOrUpdateNpmRegistry(npmrcFile: string) { + const client = await getClient(); + const gtok = await client.account.getGoogleToken(); + + const resolvedFile = resolve(npmrcFile); + + let content = ""; + + try { + content = readFileSync(resolvedFile, "utf-8"); + } catch { + content = ""; + } + const lines = content.trim().split("\n").filter((line) => !line.includes(REGISTRY_URI_ABS_PATH)); + + lines.push(getRegistryLine()); + lines.push(getRegistryAuthTokenLine(gtok.token)); + const out = `${lines.join("\n")}\n`; + + writeFileSync(resolvedFile, out, "utf8"); +} diff --git a/packages/cli/src/worker/version.ts b/packages/cli/src/worker/version.ts new file mode 100644 index 000000000..d73fe1358 --- /dev/null +++ b/packages/cli/src/worker/version.ts @@ -0,0 +1,3 @@ +export function validateVersion(version: string) { + return /^([0-9]+)\.([0-9]+)\.([0-9]+)(-[a-zA-Z_0-9-]+)?$/.test(version); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e05db91dc..2ae5ca082 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -197,6 +197,12 @@ importers: '@vertesia/common': specifier: workspace:* version: link:../common + '@vertesia/memory-cli': + specifier: workspace:* + version: link:../memory-cli + '@vertesia/memory-commands': + specifier: workspace:* + version: link:../memory-commands '@vertesia/workflow': specifier: workspace:* version: link:../workflow @@ -566,6 +572,65 @@ importers: specifier: ^4.0.16 version: 4.0.18(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) + packages/memory-cli: + dependencies: + '@vertesia/memory': + specifier: workspace:* + version: link:../memory + '@vertesia/memory-commands': + specifier: workspace:* + version: link:../memory-commands + commander: + specifier: ^14.0.2 + version: 14.0.3 + devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.0.2(jiti@2.6.1)) + '@types/node': + specifier: ^25.6.0 + version: 25.6.0 + eslint: + specifier: ^10.0.2 + version: 10.0.2(jiti@2.6.1) + typescript: + specifier: ^6.0.2 + version: 6.0.3 + typescript-eslint: + specifier: ^8.58.1 + version: 8.58.1(eslint@10.0.2(jiti@2.6.1))(typescript@6.0.3) + vitest: + specifier: ^4.0.16 + version: 4.0.18(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) + + packages/memory-commands: + dependencies: + '@vertesia/memory': + specifier: workspace:* + version: link:../memory + typescript: + specifier: ^6.0.2 + version: 6.0.3 + devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.0.2(jiti@2.6.1)) + '@types/node': + specifier: ^25.6.0 + version: 25.6.0 + eslint: + specifier: ^10.0.2 + version: 10.0.2(jiti@2.6.1) + ts-dual-module: + specifier: ^0.6.3 + version: 0.6.3(patch_hash=fa2655a6470821a60e6e431a2f196a259c9fc67e51c0a71fc38f63017cc4213f) + typescript-eslint: + specifier: ^8.58.1 + version: 8.58.1(eslint@10.0.2(jiti@2.6.1))(typescript@6.0.3) + vitest: + specifier: ^4.0.16 + version: 4.0.18(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) + packages/plugin-builder: dependencies: '@adobe/css-tools': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7cd70c0b3..d964f2ff4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,8 +1,6 @@ packages: - llumiverse/common - packages/* - - "!packages/memory-cli" - - "!packages/memory-commands" - templates/* - "!templates/worker-template" minimumReleaseAge: 4320 # 3 days in minutes, minimumReleaseAge protects against supply chain attacks. From 38d4924e73df1eb8e42a94df77ab91336eee5a95 Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Sat, 25 Apr 2026 00:48:45 +0900 Subject: [PATCH 19/75] refactor: streamline content reading in createOrUpdateNpmRegistry function --- packages/cli/src/worker/registry.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/worker/registry.ts b/packages/cli/src/worker/registry.ts index c7d0d58da..86d41960d 100644 --- a/packages/cli/src/worker/registry.ts +++ b/packages/cli/src/worker/registry.ts @@ -41,14 +41,13 @@ export async function createOrUpdateNpmRegistry(npmrcFile: string) { const gtok = await client.account.getGoogleToken(); const resolvedFile = resolve(npmrcFile); - - let content = ""; - - try { - content = readFileSync(resolvedFile, "utf-8"); - } catch { - content = ""; - } + const content = (() => { + try { + return readFileSync(resolvedFile, "utf-8"); + } catch { + return ""; + } + })(); const lines = content.trim().split("\n").filter((line) => !line.includes(REGISTRY_URI_ABS_PATH)); lines.push(getRegistryLine()); From 2ba90e7ee80b749d32296e36a8348d848d8f7d11 Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Sat, 25 Apr 2026 14:38:52 +0900 Subject: [PATCH 20/75] feat: add idToken support in auth bundle and related interfaces --- packages/cli/src/profiles/index.ts | 1 + packages/cli/src/profiles/keyring.ts | 2 ++ packages/cli/src/profiles/oauth.ts | 1 + packages/cli/src/profiles/server/index.ts | 1 + packages/common/src/oauth-server.ts | 16 ++++++++++++++++ 5 files changed, 21 insertions(+) diff --git a/packages/cli/src/profiles/index.ts b/packages/cli/src/profiles/index.ts index de7fe1300..c0c506099 100644 --- a/packages/cli/src/profiles/index.ts +++ b/packages/cli/src/profiles/index.ts @@ -163,6 +163,7 @@ export class ConfigureProfile { writeAuthBundle(result.profile, { accessToken: result.token, accessTokenExpiresAt: readResultAccessTokenExpiry(result), + idToken: result.id_token || previousBundle?.idToken, refreshToken: result.refresh_token || previousBundle?.refreshToken, refreshTokenExpiresAt: result.refresh_token_expires_at || previousBundle?.refreshTokenExpiresAt, oauthClientId: result.oauth_client_id || previousBundle?.oauthClientId, diff --git a/packages/cli/src/profiles/keyring.ts b/packages/cli/src/profiles/keyring.ts index c5edef693..58e865e3b 100644 --- a/packages/cli/src/profiles/keyring.ts +++ b/packages/cli/src/profiles/keyring.ts @@ -20,6 +20,7 @@ export interface StoredAuthBundle { version: number; accessToken?: string; accessTokenExpiresAt?: number; + idToken?: string; refreshToken?: string; refreshTokenExpiresAt?: number; oauthClientId?: string; @@ -91,6 +92,7 @@ export function writeAuthBundle(profileName: string, bundle: WritableAuthBundle) version: AUTH_BUNDLE_VERSION, accessToken: bundle.accessToken, accessTokenExpiresAt: bundle.accessTokenExpiresAt, + idToken: bundle.idToken, refreshToken: bundle.refreshToken, refreshTokenExpiresAt: bundle.refreshTokenExpiresAt, oauthClientId: bundle.oauthClientId, diff --git a/packages/cli/src/profiles/oauth.ts b/packages/cli/src/profiles/oauth.ts index f3a4b05c9..4b43c3ba6 100644 --- a/packages/cli/src/profiles/oauth.ts +++ b/packages/cli/src/profiles/oauth.ts @@ -381,6 +381,7 @@ function buildConfigResult( studio_server_url: profile.studio_server_url, zeno_server_url: profile.zeno_server_url, token: response.access_token, + id_token: response.id_token, refresh_token: response.refresh_token, expires_in: response.expires_in, oauth_client_id: oauthClientId, diff --git a/packages/cli/src/profiles/server/index.ts b/packages/cli/src/profiles/server/index.ts index e60c61744..ec44ad91d 100644 --- a/packages/cli/src/profiles/server/index.ts +++ b/packages/cli/src/profiles/server/index.ts @@ -17,6 +17,7 @@ export interface ConfigResult extends Required { studio_server_url: string; zeno_server_url: string; token: string; + id_token?: string; refresh_token?: string; expires_in?: number; access_token_expires_at?: number; diff --git a/packages/common/src/oauth-server.ts b/packages/common/src/oauth-server.ts index 5c3eaed60..d5d31d458 100644 --- a/packages/common/src/oauth-server.ts +++ b/packages/common/src/oauth-server.ts @@ -246,3 +246,19 @@ export interface OAuthAccessTokenPayload extends Omit Date: Sat, 25 Apr 2026 16:16:00 +0900 Subject: [PATCH 21/75] feat: enhance project and authentication management with new commands and options --- packages/cli/src/index.ts | 46 +++++++++++-- packages/cli/src/profiles/auth.ts | 27 ++++++-- packages/cli/src/profiles/commands.ts | 78 +++++++++++++++++++++-- packages/cli/src/profiles/index.ts | 22 +++++-- packages/cli/src/profiles/oauth.ts | 19 +++++- packages/cli/src/profiles/server/index.ts | 8 +++ packages/cli/src/projects/index.ts | 65 ++++++++++++++++++- packages/common/src/oauth-server.ts | 1 + 8 files changed, 241 insertions(+), 25 deletions(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 1a7ce0f18..d12ef57b5 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -7,9 +7,9 @@ import { listInteractions } from './interactions/index.js'; import { getPublishMemoryAction } from './memory/index.js'; import { registerObjectsCommand } from './objects/index.js'; import { getVersion, upgrade } from './package.js'; -import { createProfile, deleteProfile, listProfiles, showActiveAuthToken, showProfile, tryRefreshToken, updateCurrentProfile, updateProfile, useProfile, type CreateProfileOptions } from './profiles/commands.js'; +import { createProfile, deleteProfile, listProfiles, loginProfile, logoutProfile, showActiveAuthToken, showActiveIdToken, showProfile, tryRefreshToken, updateCurrentProfile, updateProfile, useProfile, type CreateProfileOptions } from './profiles/commands.js'; import { AVAILABLE_REGIONS, DEFAULT_REGION, getConfigFile } from './profiles/index.js'; -import { listProjects } from './projects/index.js'; +import { listProjects, useProject } from './projects/index.js'; import runInteraction from './run/index.js'; import { runHistory } from './runs/index.js'; import { registerWorkerCommand } from './worker/index.js'; @@ -25,22 +25,48 @@ program.command("upgrade") .option("-y, --yes", "Skip the confirmation prompt") .action((options: Record = {}) => upgrade(options.yes)) -program.command("projects") +const projectsRoot = program.command("projects") .description("List the projects you have access to") .action(() => { listProjects(program); - }) + }); + +projectsRoot.command("use [project]") + .description("Switch the current profile to a project without running a browser OAuth flow") + .option("-p, --project ", "The project ID to use") + .action((project: string | undefined, options: { project?: string }) => { + useProject(program, options.project || project); + }); const authRoot = program.command("auth") .description("Manage authentication") +authRoot.command("login [profile]") + .description("Authenticate a profile, creating it when it does not exist") + .option("-t, --target ", "The target environment for a new profile. Possible values are: local, dev-main, dev-preview, preview, prod or a custom URL.") + .option("-r, --region ", `Deployment region for a new profile: ${AVAILABLE_REGIONS.join(', ')}. Defaults to ${DEFAULT_REGION}. Only applies to preview and prod targets.`) + .option("-p, --project ", "Authenticate for the given project ID") + .option("-a, --account ", "The account ID to use when creating a profile") + .action(async (profile: string | undefined, options: CreateProfileOptions) => { + await loginProfile(profile, options); + }) + +authRoot.command("logout [profile]") + .description("Remove stored credentials for a profile without deleting the profile") + .action((profile: string | undefined) => logoutProfile(profile)) + authRoot.command("token") .description("Show the auth token used by the current selected profile.") .action(() => showActiveAuthToken()) +authRoot.command("id-token") + .description("Show the ID token stored for the current selected profile.") + .action(() => showActiveIdToken()) + authRoot.command("refresh") .description("Refresh the auth token used by the current profile. An alias to 'vertesia profiles refresh'.") - .action(() => updateCurrentProfile()) + .option("-p, --project ", "Refresh the current profile token for the given project ID") + .action((options: { project?: string }) => updateCurrentProfile(undefined, undefined, options)) program.command("envs [envId]") .description("List the environments you have access to") @@ -112,6 +138,11 @@ const profilesRoot = program.command("profiles") listProfiles(); }); +profilesRoot.command('list') + .description("List configuration profiles") + .action(() => { + listProfiles(); + }); profilesRoot.command('show [name]') .description("Show the configured profiles or the profile with the given name") .action((name?: string) => { @@ -141,8 +172,9 @@ profilesRoot.command('edit [name]') }); profilesRoot.command('refresh') .description("Refresh token for the current configuration profile") - .action(() => { - updateCurrentProfile(); + .option("-p, --project ", "Refresh the current profile token for the given project ID") + .action((options: { project?: string }) => { + updateCurrentProfile(undefined, undefined, options); }); profilesRoot.command('delete ') .description("delete an existing configuration profile") diff --git a/packages/cli/src/profiles/auth.ts b/packages/cli/src/profiles/auth.ts index ac708b60d..ec103013e 100644 --- a/packages/cli/src/profiles/auth.ts +++ b/packages/cli/src/profiles/auth.ts @@ -15,13 +15,19 @@ export async function ensureProfileAccessToken(profile: Profile, onResult?: OnRe return result?.token; } -export async function refreshProfileAccessToken(profile: Profile, onResult?: OnResultCallback): Promise { +export async function refreshProfileAccessToken( + profile: Profile, + onResult?: OnResultCallback, + options: { + projectId?: string; + } = {}, +): Promise { const bundle = readAuthBundle(profile.name); if (!bundle?.refreshToken || !canUseOAuthProfile(profile)) { return undefined; } - const result = await refreshOAuthSession(profile, bundle.refreshToken, bundle); + const result = await refreshOAuthSession(profile, bundle.refreshToken, bundle, options); const updater = config.updateProfile(profile.name); updater.onResultCallback = onResult; await updater.persistConfigResult(result); @@ -32,6 +38,9 @@ export async function refreshProfileAuthentication( profileName: string, onResult?: OnResultCallback, signal?: AbortSignal, + options: { + projectId?: string; + } = {}, ): Promise { const profile = config.getProfile(profileName); if (!profile) { @@ -39,15 +48,22 @@ export async function refreshProfileAuthentication( } try { - const refreshed = await refreshProfileAccessToken(profile, onResult); + const refreshed = await refreshProfileAccessToken(profile, onResult, options); if (refreshed) { return refreshed; } } catch (error) { + if (options.projectId) { + throw error; + } console.error(error instanceof Error ? error.message : String(error)); console.error('Falling back to interactive authentication.'); } + if (options.projectId) { + throw new Error('Project switching requires a stored OAuth refresh token. Run `vertesia auth refresh` without --project to authenticate again.'); + } + const updater = config.updateProfile(profileName); await updater.start(onResult, signal); return undefined; @@ -56,10 +72,13 @@ export async function refreshProfileAuthentication( export async function refreshCurrentProfileAuthentication( onResult?: OnResultCallback, signal?: AbortSignal, + options: { + projectId?: string; + } = {}, ): Promise { if (!config.current) { console.log("No profile is selected. Run `vertesia profiles use ` to select a profile"); process.exit(1); } - return refreshProfileAuthentication(config.current.name, onResult, signal); + return refreshProfileAuthentication(config.current.name, onResult, signal, options); } diff --git a/packages/cli/src/profiles/commands.ts b/packages/cli/src/profiles/commands.ts index 85cf32c4a..f2fe85a81 100644 --- a/packages/cli/src/profiles/commands.ts +++ b/packages/cli/src/profiles/commands.ts @@ -3,7 +3,7 @@ import colors from 'ansi-colors'; import enquirer from "enquirer"; import jwt from 'jsonwebtoken'; import { AVAILABLE_REGIONS, DEFAULT_REGION, Region, config, getConfigUrl, getServerUrls, shouldRefreshProfileToken } from "./index.js"; -import { deleteAuthBundle, getAccessTokenExpiry, writeAuthBundle } from "./keyring.js"; +import { deleteAuthBundle, getAccessTokenExpiry, readAuthBundle, writeAuthBundle } from "./keyring.js"; import { ensureProfileAccessToken, refreshCurrentProfileAuthentication, refreshProfileAuthentication } from './auth.js'; import { ConfigResult } from './server/index.js'; const { prompt } = enquirer; @@ -98,12 +98,49 @@ export async function showActiveAuthToken() { } } +export async function showActiveIdToken() { + if (config.profiles.length === 0) { + console.log('No profiles are defined. Run `vertesia profiles create` to add a new profile.'); + return; + } + if (!config.current) { + console.log('No profile is selected. Run `vertesia auth refresh` to refresh the token'); + return; + } + + const bundle = readAuthBundle(config.current.name); + if (!bundle?.idToken) { + console.log('No ID token is stored for the current profile. Run `vertesia auth refresh` to authenticate again.'); + return; + } + console.log(bundle.idToken); +} + export function deleteProfile(name: string) { deleteAuthBundle(name); config.remove(name).save(); } +export function logoutProfile(name?: string) { + const profileName = name || config.current?.name; + if (!profileName) { + console.log("No profile is selected. Run `vertesia profiles use ` to select a profile"); + return; + } + if (!config.getProfile(profileName)) { + console.error(`Profile ${profileName} not found`); + process.exit(1); + } + const profile = config.getProfile(profileName); + if (profile?.apikey) { + delete profile.apikey; + config.save(); + } + deleteAuthBundle(profileName); + console.log(`Logged out of profile ${profileName}.`); +} + export interface CreateProfileOptions { target?: string, region?: string, @@ -232,6 +269,20 @@ export async function createProfile(name?: string, options: CreateProfileOptions return name!; } +export async function loginProfile(name?: string, options: CreateProfileOptions & RefreshProfileOptions = {}) { + const profile = name + ? config.getProfile(name) + : config.current; + if (profile) { + await refreshProfileAuthentication(profile.name, options.onResult, undefined, { + projectId: options.project, + }); + return profile.name; + } + + return createProfile(name, options); +} + export async function updateProfile(name?: string, onResult?: OnResultCallback, signal?: AbortSignal) { if (!name) { name = await selectProfile("Select the profile to update"); @@ -244,15 +295,32 @@ export async function updateProfile(name?: string, onResult?: OnResultCallback, await config.updateProfile(name).start(onResult, signal); } -export async function refreshProfile(name?: string, onResult?: OnResultCallback, signal?: AbortSignal): Promise { +export interface RefreshProfileOptions { + project?: string; +} + +export async function refreshProfile( + name?: string, + onResult?: OnResultCallback, + signal?: AbortSignal, + options: RefreshProfileOptions = {}, +): Promise { if (!name) { name = await selectProfile("Select the profile to refresh"); } - return refreshProfileAuthentication(name, onResult, signal); + return refreshProfileAuthentication(name, onResult, signal, { + projectId: options.project, + }); } -export function updateCurrentProfile(onResult?: OnResultCallback, signal?: AbortSignal): Promise { - return refreshCurrentProfileAuthentication(onResult, signal).then(() => undefined); +export function updateCurrentProfile( + onResult?: OnResultCallback, + signal?: AbortSignal, + options: RefreshProfileOptions = {}, +): Promise { + return refreshCurrentProfileAuthentication(onResult, signal, { + projectId: options.project, + }).then(() => undefined); } diff --git a/packages/cli/src/profiles/index.ts b/packages/cli/src/profiles/index.ts index c0c506099..23a563257 100644 --- a/packages/cli/src/profiles/index.ts +++ b/packages/cli/src/profiles/index.ts @@ -5,7 +5,7 @@ import { join } from "path"; import { readJsonFile, writeJsonFile } from "../utils/stdio.js"; import { ConfigPayload, ConfigResult, startConfigSession } from "./server/index.js"; import type { OnResultCallback } from "./commands.js"; -import { canUseOAuthProfile, startOAuthSession } from "./oauth.js"; +import { canUseOAuthProfile, OAuthUnavailableError, startOAuthSession } from "./oauth.js"; import { deleteAuthBundle, getAccessTokenExpiry, hasStoredAccessToken, isKeyringAvailable, readAuthBundle, readProfileAccessToken, writeAuthBundle } from "./keyring.js"; export function getConfigFile(path?: string) { @@ -215,9 +215,16 @@ export class ConfigureProfile { async start(onResult?: OnResultCallback, signal?: AbortSignal) { this.onResultCallback = onResult; if (canUseOAuthProfile(this.data)) { - const result = await startOAuthSession(this.data as Pick & Partial>, signal); - await this.applyConfigResult(result, { logCompletion: true }); - return; + try { + const result = await startOAuthSession(this.data as Pick & Partial>, signal); + await this.applyConfigResult(result, { logCompletion: true }); + return; + } catch (error: unknown) { + if (!(error instanceof OAuthUnavailableError)) { + throw error; + } + console.log('OAuth login is not available for this endpoint. Falling back to legacy CLI login.'); + } } await this.startLegacySession(signal); } @@ -231,7 +238,7 @@ export class Config { constructor(data?: ProfilesData) { this.profiles = data?.profiles || []; if (data?.default) { - this.use(data.default); + this.current = this.profiles.find(p => p.name === data.default); } } @@ -377,7 +384,10 @@ export class Config { } } if (data.default) { - this.use(data.default) + this.current = this.profiles.find(p => p.name === data.default); + if (!this.current) { + needsSave = true; + } } else { this.current = undefined; } diff --git a/packages/cli/src/profiles/oauth.ts b/packages/cli/src/profiles/oauth.ts index 4b43c3ba6..0808fdb2e 100644 --- a/packages/cli/src/profiles/oauth.ts +++ b/packages/cli/src/profiles/oauth.ts @@ -26,6 +26,13 @@ interface PkcePair { challenge: string; } +export class OAuthUnavailableError extends Error { + constructor(message: string) { + super(message); + this.name = 'OAuthUnavailableError'; + } +} + export function canUseOAuthProfile(profile: Partial>): boolean { if (!profile.studio_server_url) { return false; @@ -91,13 +98,16 @@ export async function refreshOAuthSession( profile: OAuthProfile, refreshToken: string, bundle?: StoredAuthBundle, + options: { + projectId?: string; + } = {}, ): Promise { assertOAuthProfile(profile); const metadata = await fetchAuthorizationServerMetadata(profile.studio_server_url); const clientId = bundle?.oauthClientId || getOAuthClientId(profile); const resource = bundle?.oauthResource || readTokenRefs(bundle?.accessToken).audience || getOAuthResource(metadata); - const response = await exchangeRefreshToken(metadata, clientId, refreshToken, resource); + const response = await exchangeRefreshToken(metadata, clientId, refreshToken, resource, options.projectId); return buildConfigResult(profile, response, clientId, resource); } @@ -125,6 +135,9 @@ async function fetchAuthorizationServerMetadata(studioServerUrl: string): Promis }); if (!response.ok) { + if (response.status === 404 || response.status === 501) { + throw new OAuthUnavailableError(`OAuth discovery is not available at ${studioServerUrl}.`); + } throw new Error(`Failed to load OAuth authorization metadata from ${studioServerUrl} (${response.status} ${response.statusText}).`); } @@ -307,6 +320,7 @@ async function exchangeRefreshToken( clientId: string, refreshToken: string, resource: string, + projectId?: string, ): Promise { const body = new URLSearchParams({ grant_type: 'refresh_token', @@ -314,6 +328,9 @@ async function exchangeRefreshToken( client_id: clientId, resource, }); + if (projectId) { + body.set('project_id', projectId); + } return exchangeToken(metadata.token_endpoint, body); } diff --git a/packages/cli/src/profiles/server/index.ts b/packages/cli/src/profiles/server/index.ts index ec44ad91d..62fc4fd48 100644 --- a/packages/cli/src/profiles/server/index.ts +++ b/packages/cli/src/profiles/server/index.ts @@ -54,6 +54,7 @@ export async function startConfigSession( } else { process.off('SIGINT', onInterrupt); process.off('SIGTERM', onInterrupt); + process.stdin.off('data', onInput); } } @@ -83,6 +84,12 @@ export async function startConfigSession( process.exit(130); } + function onInput(chunk: Buffer | string) { + if (String(chunk).includes('\x03')) { + onInterrupt(); + } + } + if (signal?.aborted) { return; } @@ -92,6 +99,7 @@ export async function startConfigSession( } else { process.once('SIGINT', onInterrupt); process.once('SIGTERM', onInterrupt); + process.stdin.on('data', onInput); } const code = randomInt(1000, 9999); diff --git a/packages/cli/src/projects/index.ts b/packages/cli/src/projects/index.ts index e49fac637..8824cbdaa 100644 --- a/packages/cli/src/projects/index.ts +++ b/packages/cli/src/projects/index.ts @@ -1,6 +1,18 @@ import colors from "ansi-colors"; import { Command } from "commander"; import { getClient } from "../client.js"; +import { config } from "../profiles/index.js"; +import { refreshCurrentProfileAuthentication } from "../profiles/auth.js"; +import enquirer from "enquirer"; + +const { prompt } = enquirer; + +interface ProjectChoice { + id: string; + name: string; + account: string; + restricted?: boolean; +} export async function listProjects(program: Command) { const client = await getClient(program); @@ -13,6 +25,55 @@ export async function listProjects(program: Command) { const projects = await client.projects.list(); projects.map(project => { const check = activeProjectId === project.id ? " " + colors.symbols.check : ""; - console.log(project.name + ` [${project.id}]` + check); + const restricted = project.restricted ? ` ${colors.dim('(restricted)')}` : ""; + console.log(project.name + ` [${project.id}]` + restricted + check); }) -} \ No newline at end of file +} + +export async function useProject(program: Command, projectId?: string) { + if (!config.current) { + console.error("No profile is selected. Run `vertesia profiles use ` to select a profile"); + process.exit(1); + } + + const client = await getClient(program); + const projects = await client.projects.list(); + const selectedProjectId = projectId || await selectProject(projects); + const selectedProject = projects.find(project => project.id === selectedProjectId); + if (!selectedProject) { + console.error(`Project ${selectedProjectId} not found or not accessible.`); + process.exit(1); + } + if (selectedProject.restricted) { + console.error(`Project ${selectedProject.name} [${selectedProject.id}] is visible but restricted. Select a project you have direct access to.`); + process.exit(1); + } + + await refreshCurrentProfileAuthentication(undefined, undefined, { + projectId: selectedProject.id, + }); + console.log(`Selected project ${selectedProject.name} [${selectedProject.id}]`); +} + +async function selectProject(projects: ProjectChoice[]): Promise { + const accessibleProjects = projects.filter(project => !project.restricted); + if (!accessibleProjects.length) { + console.error('No accessible projects found.'); + process.exit(1); + } + + const response = await prompt<{ project?: string }>({ + type: 'select', + name: 'project', + message: 'Select the project to use', + choices: accessibleProjects.map(project => ({ + name: project.id, + message: `${project.name} [${project.id}]`, + })), + }); + if (!response.project) { + console.error('No project selected'); + process.exit(1); + } + return response.project; +} diff --git a/packages/common/src/oauth-server.ts b/packages/common/src/oauth-server.ts index d5d31d458..93d04c710 100644 --- a/packages/common/src/oauth-server.ts +++ b/packages/common/src/oauth-server.ts @@ -199,6 +199,7 @@ export interface OAuthTokenRequestRefreshToken { refresh_token: string; client_id: string; resource?: string; + project_id?: string; client_secret?: string; } From 7d61656de9467054768d904320b8928b77a5baec Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Sat, 25 Apr 2026 16:58:37 +0900 Subject: [PATCH 22/75] feat: enhance OAuth client integration with discovery and error handling --- packages/cli/src/profiles/oauth.ts | 72 +++++++++++++++++++++++---- packages/client/src/OAuthServerApi.ts | 24 +++++++-- packages/client/src/client.ts | 2 +- 3 files changed, 81 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/profiles/oauth.ts b/packages/cli/src/profiles/oauth.ts index 0808fdb2e..6915f952f 100644 --- a/packages/cli/src/profiles/oauth.ts +++ b/packages/cli/src/profiles/oauth.ts @@ -26,6 +26,11 @@ interface PkcePair { challenge: string; } +interface OAuthDiscovery { + metadata: OAuthAuthorizationServerMetadata; + serverUrl: string; +} + export class OAuthUnavailableError extends Error { constructor(message: string) { super(message); @@ -50,8 +55,8 @@ export function canUseOAuthProfile(profile: Partial): string { - return new URL(OAUTH_CLIENT_METADATA_PATH, withTrailingSlash(profile.studio_server_url)).toString(); +export function getOAuthClientId(oauthServerUrl: string): string { + return new URL(OAUTH_CLIENT_METADATA_PATH, withTrailingSlash(oauthServerUrl)).toString(); } export function getOAuthResource(metadata: Pick): string { @@ -61,8 +66,8 @@ export function getOAuthResource(metadata: Pick { assertOAuthProfile(profile); - const metadata = await fetchAuthorizationServerMetadata(profile.studio_server_url); - const clientId = getOAuthClientId(profile); + const { metadata, serverUrl } = await discoverAuthorizationServer(profile); + const clientId = getOAuthClientId(serverUrl); const resource = getOAuthResource(metadata); const scope = DEFAULT_OAUTH_SCOPE; const pkce = createPkcePair(); @@ -104,8 +109,8 @@ export async function refreshOAuthSession( ): Promise { assertOAuthProfile(profile); - const metadata = await fetchAuthorizationServerMetadata(profile.studio_server_url); - const clientId = bundle?.oauthClientId || getOAuthClientId(profile); + const { metadata, serverUrl } = await discoverAuthorizationServer(profile); + const clientId = getOAuthClientId(serverUrl); const resource = bundle?.oauthResource || readTokenRefs(bundle?.accessToken).audience || getOAuthResource(metadata); const response = await exchangeRefreshToken(metadata, clientId, refreshToken, resource, options.projectId); return buildConfigResult(profile, response, clientId, resource); @@ -127,8 +132,53 @@ function withTrailingSlash(url: string): string { return url.endsWith('/') ? url : `${url}/`; } -async function fetchAuthorizationServerMetadata(studioServerUrl: string): Promise { - const response = await fetch(new URL(OAUTH_AUTHORIZATION_SERVER_PATH, withTrailingSlash(studioServerUrl)).toString(), { +async function discoverAuthorizationServer(profile: Pick): Promise { + const candidates = getOAuthServerUrlCandidates(profile); + let unavailableError: OAuthUnavailableError | undefined; + for (const candidate of candidates) { + try { + return { + metadata: await fetchAuthorizationServerMetadata(candidate), + serverUrl: candidate, + }; + } catch (error) { + if (error instanceof OAuthUnavailableError) { + unavailableError = error; + continue; + } + throw error; + } + } + throw unavailableError || new OAuthUnavailableError(`OAuth discovery is not available for ${profile.studio_server_url}.`); +} + +function getOAuthServerUrlCandidates(profile: Pick): string[] { + if (process.env.VERTESIA_TOKEN_SERVER_URL) { + return [process.env.VERTESIA_TOKEN_SERVER_URL]; + } + + const studioUrl = new URL(profile.studio_server_url); + const isLoopbackHost = studioUrl.hostname === 'localhost' || studioUrl.hostname === '127.0.0.1'; + if (isLoopbackHost) { + return [`${studioUrl.protocol}//${studioUrl.hostname}:8093`]; + } + + const candidates = [new URL('/', studioUrl).toString()]; + if (studioUrl.hostname.startsWith('api')) { + const stsHost = studioUrl.hostname.replace('api-preview.', 'api.').replace(/^api/, 'sts'); + candidates.push(`${studioUrl.protocol}//${stsHost}`); + } + + if (studioUrl.hostname.endsWith('.api.dev1.vertesia.io') || studioUrl.hostname.endsWith('.ui.dev1.vertesia.io')) { + candidates.push('https://sts.dev1.vertesia.io'); + } + + candidates.push('https://sts.dev1.vertesia.io'); + return Array.from(new Set(candidates.map((candidate) => candidate.replace(/\/+$/, '')))); +} + +async function fetchAuthorizationServerMetadata(oauthServerUrl: string): Promise { + const response = await fetch(new URL(OAUTH_AUTHORIZATION_SERVER_PATH, withTrailingSlash(oauthServerUrl)).toString(), { headers: { Accept: 'application/json', }, @@ -136,14 +186,14 @@ async function fetchAuthorizationServerMetadata(studioServerUrl: string): Promis if (!response.ok) { if (response.status === 404 || response.status === 501) { - throw new OAuthUnavailableError(`OAuth discovery is not available at ${studioServerUrl}.`); + throw new OAuthUnavailableError(`OAuth discovery is not available at ${oauthServerUrl}.`); } - throw new Error(`Failed to load OAuth authorization metadata from ${studioServerUrl} (${response.status} ${response.statusText}).`); + throw new Error(`Failed to load OAuth authorization metadata from ${oauthServerUrl} (${response.status} ${response.statusText}).`); } const metadata = await response.json() as Partial; if (!metadata.authorization_endpoint || !metadata.token_endpoint || !metadata.issuer) { - throw new Error(`Invalid OAuth authorization metadata returned by ${studioServerUrl}.`); + throw new Error(`Invalid OAuth authorization metadata returned by ${oauthServerUrl}.`); } return metadata as OAuthAuthorizationServerMetadata; } diff --git a/packages/client/src/OAuthServerApi.ts b/packages/client/src/OAuthServerApi.ts index 1bbf0e8a9..81f41d2b2 100644 --- a/packages/client/src/OAuthServerApi.ts +++ b/packages/client/src/OAuthServerApi.ts @@ -1,15 +1,29 @@ -import { ApiTopic } from '@vertesia/api-fetch-client'; +import { ClientBase, type IRequestParamsWithPayload } from '@vertesia/api-fetch-client'; import type { ApproveOAuthAuthorizationRequestPayload, CreateOAuthAuthorizationRequestPayload, OAuthAuthorizationDecisionResponse, OAuthAuthorizationRequest, } from '@vertesia/common'; -import type { ClientBase } from '@vertesia/api-fetch-client'; -export default class OAuthServerApi extends ApiTopic { - constructor(parent: ClientBase) { - super(parent, '/oauth'); +export default class OAuthServerApi extends ClientBase { + constructor(private readonly parent: ClientBase, baseUrl?: string) { + super(new URL('/oauth', `${baseUrl || parent.baseUrl}/`).toString(), parent._fetch); + this.createServerError = parent.createServerError; + this.errorFactory = parent.errorFactory; + this.verboseErrors = parent.verboseErrors; + } + + get headers() { + return this.parent.headers; + } + + createRequest(url: string, init: RequestInit): Promise { + return this.parent.createRequest(url, init); + } + + handleResponse(req: Request, res: Response, params: IRequestParamsWithPayload | undefined): Promise { + return this.parent.handleResponse(req, res, params); } createAuthorizationRequest(payload: CreateOAuthAuthorizationRequestPayload): Promise { diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index f1ada8aef..0684e799b 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -199,7 +199,7 @@ export class VertesiaClient extends AbstractFetchClient { this.sessionTags = opts.sessionTags; this.oauthClients = new OAuthClientsApi(this); this.oauthGrants = new OAuthGrantsApi(this); - this.oauthServer = new OAuthServerApi(this); + this.oauthServer = new OAuthServerApi(this, this.tokenServerUrl); this.oauthProviders = new OAuthProvidersApi(this); this.remoteMcpConnections = new RemoteMcpConnectionsApi(this); } From 50286ccf792194281c7794dc0bc600dc5de945a1 Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Sat, 25 Apr 2026 17:02:38 +0900 Subject: [PATCH 23/75] feat: add node runtime detection and enhance initial headers with encoding support --- packages/api-fetch-client/src/client.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/api-fetch-client/src/client.ts b/packages/api-fetch-client/src/client.ts index 76290fc1d..fe6767b08 100644 --- a/packages/api-fetch-client/src/client.ts +++ b/packages/api-fetch-client/src/client.ts @@ -6,6 +6,14 @@ function isAuthorizationHeaderSet(headers: HeadersInit | undefined): boolean { return "authorization" in headers; } +function isNodeRuntime(): boolean { + const runtime = globalThis as typeof globalThis & { + process?: { versions?: { node?: string } }; + window?: unknown; + }; + return typeof runtime.process?.versions?.node === "string" && typeof runtime.window === "undefined"; +} + export class AbstractFetchClient> extends ClientBase { headers: Record; @@ -23,7 +31,11 @@ export class AbstractFetchClient> extends Clien } get initialHeaders() { - return { accept: 'application/json' }; + const headers: Record = { accept: 'application/json' }; + if (isNodeRuntime()) { + headers['accept-encoding'] = 'br, gzip, deflate'; + } + return headers; } /** From e9c4d9fe3fa66d02a4f546d241f7a7ab57e0b1a3 Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Sat, 25 Apr 2026 17:05:09 +0900 Subject: [PATCH 24/75] feat: rename and enhance server runtime detection for improved header handling --- packages/api-fetch-client/src/client.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/api-fetch-client/src/client.ts b/packages/api-fetch-client/src/client.ts index fe6767b08..d40b72915 100644 --- a/packages/api-fetch-client/src/client.ts +++ b/packages/api-fetch-client/src/client.ts @@ -6,12 +6,17 @@ function isAuthorizationHeaderSet(headers: HeadersInit | undefined): boolean { return "authorization" in headers; } -function isNodeRuntime(): boolean { +function isServerFetchRuntime(): boolean { const runtime = globalThis as typeof globalThis & { - process?: { versions?: { node?: string } }; + Bun?: unknown; + process?: { versions?: { bun?: string; node?: string } }; window?: unknown; }; - return typeof runtime.process?.versions?.node === "string" && typeof runtime.window === "undefined"; + return typeof runtime.window === "undefined" && ( + typeof runtime.process?.versions?.node === "string" + || typeof runtime.process?.versions?.bun === "string" + || typeof runtime.Bun !== "undefined" + ); } export class AbstractFetchClient> extends ClientBase { @@ -32,7 +37,7 @@ export class AbstractFetchClient> extends Clien get initialHeaders() { const headers: Record = { accept: 'application/json' }; - if (isNodeRuntime()) { + if (isServerFetchRuntime()) { headers['accept-encoding'] = 'br, gzip, deflate'; } return headers; From fe2a927e02c545cc27a69775ab4370f60007d435 Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Sat, 25 Apr 2026 18:07:41 +0900 Subject: [PATCH 25/75] feat: enhance OAuth profile to include config_url and add device authorization request interfaces --- packages/cli/src/profiles/index.ts | 2 +- packages/cli/src/profiles/oauth.ts | 56 ++++++++++++++++++++++------- packages/common/src/oauth-server.ts | 26 +++++++++++++- 3 files changed, 70 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/profiles/index.ts b/packages/cli/src/profiles/index.ts index 23a563257..d21624504 100644 --- a/packages/cli/src/profiles/index.ts +++ b/packages/cli/src/profiles/index.ts @@ -216,7 +216,7 @@ export class ConfigureProfile { this.onResultCallback = onResult; if (canUseOAuthProfile(this.data)) { try { - const result = await startOAuthSession(this.data as Pick & Partial>, signal); + const result = await startOAuthSession(this.data as Pick & Partial>, signal); await this.applyConfigResult(result, { logCompletion: true }); return; } catch (error: unknown) { diff --git a/packages/cli/src/profiles/oauth.ts b/packages/cli/src/profiles/oauth.ts index 6915f952f..798df71d8 100644 --- a/packages/cli/src/profiles/oauth.ts +++ b/packages/cli/src/profiles/oauth.ts @@ -13,7 +13,7 @@ const OAUTH_CALLBACK_PATH = '/oauth/callback'; const OAUTH_LOOPBACK_HOST = '127.0.0.1'; const DEFAULT_OAUTH_SCOPE = 'openid profile'; -type OAuthProfile = Pick & Partial>; +type OAuthProfile = Pick & Partial>; interface TokenRefs { account?: string; @@ -132,13 +132,14 @@ function withTrailingSlash(url: string): string { return url.endsWith('/') ? url : `${url}/`; } -async function discoverAuthorizationServer(profile: Pick): Promise { +async function discoverAuthorizationServer(profile: Pick & Partial>): Promise { const candidates = getOAuthServerUrlCandidates(profile); let unavailableError: OAuthUnavailableError | undefined; for (const candidate of candidates) { try { + const metadata = await fetchAuthorizationServerMetadata(candidate); return { - metadata: await fetchAuthorizationServerMetadata(candidate), + metadata: applyProfileAuthorizationEndpoint(metadata, profile), serverUrl: candidate, }; } catch (error) { @@ -152,6 +153,20 @@ async function discoverAuthorizationServer(profile: Pick>, +): OAuthAuthorizationServerMetadata { + if (!profile.config_url) { + return metadata; + } + const configUrl = new URL(profile.config_url); + return { + ...metadata, + authorization_endpoint: new URL('/oauth/authorize', configUrl.origin).toString(), + }; +} + function getOAuthServerUrlCandidates(profile: Pick): string[] { if (process.env.VERTESIA_TOKEN_SERVER_URL) { return [process.env.VERTESIA_TOKEN_SERVER_URL]; @@ -160,7 +175,7 @@ function getOAuthServerUrlCandidates(profile: Pick const studioUrl = new URL(profile.studio_server_url); const isLoopbackHost = studioUrl.hostname === 'localhost' || studioUrl.hostname === '127.0.0.1'; if (isLoopbackHost) { - return [`${studioUrl.protocol}//${studioUrl.hostname}:8093`]; + return ['https://sts.dev1.vertesia.io']; } const candidates = [new URL('/', studioUrl).toString()]; @@ -178,11 +193,16 @@ function getOAuthServerUrlCandidates(profile: Pick } async function fetchAuthorizationServerMetadata(oauthServerUrl: string): Promise { - const response = await fetch(new URL(OAUTH_AUTHORIZATION_SERVER_PATH, withTrailingSlash(oauthServerUrl)).toString(), { - headers: { - Accept: 'application/json', - }, - }); + let response: Response; + try { + response = await fetch(new URL(OAUTH_AUTHORIZATION_SERVER_PATH, withTrailingSlash(oauthServerUrl)).toString(), { + headers: { + Accept: 'application/json', + }, + }); + } catch (error) { + throw new OAuthUnavailableError(`OAuth discovery is not reachable at ${oauthServerUrl}: ${error instanceof Error ? error.message : String(error)}`); + } if (!response.ok) { if (response.status === 404 || response.status === 501) { @@ -219,11 +239,13 @@ async function createAuthorizationCallback(expectedState: string, signal?: Abort if (req.method !== 'GET' || requestUrl.pathname !== OAUTH_CALLBACK_PATH) { res.statusCode = 404; + res.setHeader('Connection', 'close'); res.end(); return; } if (settled) { res.statusCode = 409; + res.setHeader('Connection', 'close'); res.end('Authentication already completed.'); return; } @@ -236,6 +258,7 @@ async function createAuthorizationCallback(expectedState: string, signal?: Abort if (error) { settled = true; res.statusCode = 400; + res.setHeader('Connection', 'close'); res.setHeader('Content-Type', 'text/plain; charset=utf-8'); res.end(errorDescription || error); rejectAuthorization(new Error(errorDescription || error)); @@ -246,6 +269,7 @@ async function createAuthorizationCallback(expectedState: string, signal?: Abort if (!state || state !== expectedState) { settled = true; res.statusCode = 400; + res.setHeader('Connection', 'close'); res.setHeader('Content-Type', 'text/plain; charset=utf-8'); res.end('State mismatch.'); rejectAuthorization(new Error('OAuth state mismatch.')); @@ -256,6 +280,7 @@ async function createAuthorizationCallback(expectedState: string, signal?: Abort if (!code) { settled = true; res.statusCode = 400; + res.setHeader('Connection', 'close'); res.setHeader('Content-Type', 'text/plain; charset=utf-8'); res.end('No authorization code was returned.'); rejectAuthorization(new Error('OAuth authorization code missing from callback.')); @@ -265,10 +290,12 @@ async function createAuthorizationCallback(expectedState: string, signal?: Abort settled = true; res.statusCode = 200; + res.setHeader('Connection', 'close'); res.setHeader('Content-Type', 'text/plain; charset=utf-8'); - res.end('Authentication complete. You can close this window.'); - resolveAuthorization(code); - closeServer(); + res.end('Authentication complete. You can close this window.', () => { + resolveAuthorization(code); + closeServer(); + }); }); const onAbort = () => { if (settled) { @@ -282,6 +309,11 @@ async function createAuthorizationCallback(expectedState: string, signal?: Abort const closeServer = () => { if (server.listening) { server.close(); + server.closeIdleConnections?.(); + const closeConnectionsTimer = setTimeout(() => { + server.closeAllConnections?.(); + }, 100); + closeConnectionsTimer.unref?.(); } if (signal) { signal.removeEventListener('abort', onAbort); diff --git a/packages/common/src/oauth-server.ts b/packages/common/src/oauth-server.ts index 93d04c710..08b7c9e1f 100644 --- a/packages/common/src/oauth-server.ts +++ b/packages/common/src/oauth-server.ts @@ -131,6 +131,7 @@ export interface OAuthAuthorizationServerMetadata { token_endpoint_auth_methods_supported: string[]; scopes_supported: string[]; client_id_metadata_document_supported?: boolean; + device_authorization_endpoint?: string; } export interface OAuthClientMetadataDocument { @@ -184,6 +185,22 @@ export interface OAuthAuthorizationDecisionResponse { redirect_url: string; } +export interface OAuthDeviceAuthorizationRequest { + client_id: string; + resource?: string; + scope?: string; + project_id?: string; +} + +export interface OAuthDeviceAuthorizationResponse { + device_code: string; + user_code: string; + verification_uri: string; + verification_uri_complete: string; + expires_in: number; + interval: number; +} + export interface OAuthTokenRequestAuthorizationCode { grant_type: 'authorization_code'; code: string; @@ -203,7 +220,14 @@ export interface OAuthTokenRequestRefreshToken { client_secret?: string; } -export type OAuthTokenRequest = OAuthTokenRequestAuthorizationCode | OAuthTokenRequestRefreshToken; +export interface OAuthTokenRequestDeviceCode { + grant_type: 'urn:ietf:params:oauth:grant-type:device_code'; + device_code: string; + client_id: string; + client_secret?: string; +} + +export type OAuthTokenRequest = OAuthTokenRequestAuthorizationCode | OAuthTokenRequestRefreshToken | OAuthTokenRequestDeviceCode; export interface OAuthTokenResponse { access_token: string; From 8fa344081fe0d1faa435145d0b4cce1ef373f812 Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Sat, 25 Apr 2026 20:49:26 +0900 Subject: [PATCH 26/75] feat: use device flow for cli oauth --- packages/cli/src/profiles/oauth.ts | 332 +++++++++++--------------- packages/client/src/OAuthServerApi.ts | 10 + packages/common/src/oauth-server.ts | 2 +- 3 files changed, 150 insertions(+), 194 deletions(-) diff --git a/packages/cli/src/profiles/oauth.ts b/packages/cli/src/profiles/oauth.ts index 798df71d8..4c9f31492 100644 --- a/packages/cli/src/profiles/oauth.ts +++ b/packages/cli/src/profiles/oauth.ts @@ -1,16 +1,12 @@ -import crypto from 'node:crypto'; import jwt from 'jsonwebtoken'; import open from 'open'; -import { startServer } from './server/server.js'; -import type { OAuthAuthorizationServerMetadata, OAuthTokenResponse } from '@vertesia/common'; +import type { OAuthAuthorizationServerMetadata, OAuthDeviceAuthorizationResponse, OAuthTokenResponse } from '@vertesia/common'; import type { Profile } from './index.js'; import type { StoredAuthBundle } from './keyring.js'; import type { ConfigResult } from './server/index.js'; const OAUTH_AUTHORIZATION_SERVER_PATH = '/.well-known/oauth-authorization-server'; const OAUTH_CLIENT_METADATA_PATH = '/.well-known/oauth-client/vertesia-cli'; -const OAUTH_CALLBACK_PATH = '/oauth/callback'; -const OAUTH_LOOPBACK_HOST = '127.0.0.1'; const DEFAULT_OAUTH_SCOPE = 'openid profile'; type OAuthProfile = Pick & Partial>; @@ -21,11 +17,6 @@ interface TokenRefs { audience?: string; } -interface PkcePair { - verifier: string; - challenge: string; -} - interface OAuthDiscovery { metadata: OAuthAuthorizationServerMetadata; serverUrl: string; @@ -70,33 +61,23 @@ export async function startOAuthSession(profile: OAuthProfile, signal?: AbortSig const clientId = getOAuthClientId(serverUrl); const resource = getOAuthResource(metadata); const scope = DEFAULT_OAUTH_SCOPE; - const pkce = createPkcePair(); - const state = crypto.randomUUID(); - - const callback = await createAuthorizationCallback(state, signal); - const redirectUri = callback.redirectUri; - const authorizeUrl = buildAuthorizeUrl(metadata, { + const deviceAuthorization = await createDeviceAuthorization(metadata, { clientId, - redirectUri, resource, scope, - state, - challenge: pkce.challenge, projectId: profile.project, }); + const verificationUrl = buildDeviceVerificationUrl(profile, deviceAuthorization); - console.log('Opening browser to', authorizeUrl); - open(authorizeUrl).catch((error) => { + console.log('Opening browser to', verificationUrl); + console.log('The session code is', deviceAuthorization.user_code); + console.log('Waiting for browser authorization...'); + open(verificationUrl).catch((error) => { console.error('Unable to open browser:', error instanceof Error ? error.message : String(error)); }); - try { - const code = await callback.waitForCode(); - const response = await exchangeAuthorizationCode(metadata, clientId, code, pkce.verifier, redirectUri, resource); - return buildConfigResult(profile, response, clientId, resource); - } finally { - callback.close(); - } + const response = await pollDeviceToken(metadata, clientId, deviceAuthorization, signal); + return buildConfigResult(profile, response, clientId, resource); } export async function refreshOAuthSession( @@ -218,183 +199,117 @@ async function fetchAuthorizationServerMetadata(oauthServerUrl: string): Promise return metadata as OAuthAuthorizationServerMetadata; } -function createPkcePair(): PkcePair { - const verifier = crypto.randomBytes(32).toString('base64url'); - const challenge = crypto.createHash('sha256').update(verifier).digest('base64url'); - return { verifier, challenge }; -} - -async function createAuthorizationCallback(expectedState: string, signal?: AbortSignal) { - let settled = false; - let rejectAuthorization: (error: Error) => void = () => {}; - let resolveAuthorization: (code: string) => void = () => {}; +async function createDeviceAuthorization( + metadata: OAuthAuthorizationServerMetadata, + input: { + clientId: string; + resource: string; + scope: string; + projectId?: string; + }, +): Promise { + if (!metadata.device_authorization_endpoint) { + throw new OAuthUnavailableError('OAuth device authorization is not available for this endpoint.'); + } - const waitForCode = new Promise((resolve, reject) => { - resolveAuthorization = resolve; - rejectAuthorization = reject; + const body = new URLSearchParams({ + client_id: input.clientId, + resource: input.resource, + scope: input.scope, }); + if (input.projectId) { + body.set('project_id', input.projectId); + } - const server = await startServer((req, res) => { - const requestUrl = new URL(req.url || '/', `http://${req.headers.host || OAUTH_LOOPBACK_HOST}`); - - if (req.method !== 'GET' || requestUrl.pathname !== OAUTH_CALLBACK_PATH) { - res.statusCode = 404; - res.setHeader('Connection', 'close'); - res.end(); - return; - } - if (settled) { - res.statusCode = 409; - res.setHeader('Connection', 'close'); - res.end('Authentication already completed.'); - return; - } - - const state = requestUrl.searchParams.get('state'); - const code = requestUrl.searchParams.get('code'); - const error = requestUrl.searchParams.get('error'); - const errorDescription = requestUrl.searchParams.get('error_description'); - - if (error) { - settled = true; - res.statusCode = 400; - res.setHeader('Connection', 'close'); - res.setHeader('Content-Type', 'text/plain; charset=utf-8'); - res.end(errorDescription || error); - rejectAuthorization(new Error(errorDescription || error)); - closeServer(); - return; - } - - if (!state || state !== expectedState) { - settled = true; - res.statusCode = 400; - res.setHeader('Connection', 'close'); - res.setHeader('Content-Type', 'text/plain; charset=utf-8'); - res.end('State mismatch.'); - rejectAuthorization(new Error('OAuth state mismatch.')); - closeServer(); - return; - } - - if (!code) { - settled = true; - res.statusCode = 400; - res.setHeader('Connection', 'close'); - res.setHeader('Content-Type', 'text/plain; charset=utf-8'); - res.end('No authorization code was returned.'); - rejectAuthorization(new Error('OAuth authorization code missing from callback.')); - closeServer(); - return; - } - - settled = true; - res.statusCode = 200; - res.setHeader('Connection', 'close'); - res.setHeader('Content-Type', 'text/plain; charset=utf-8'); - res.end('Authentication complete. You can close this window.', () => { - resolveAuthorization(code); - closeServer(); - }); + const response = await fetch(metadata.device_authorization_endpoint, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: body.toString(), }); - const onAbort = () => { - if (settled) { - return; - } - settled = true; - closeServer(); - rejectAuthorization(new Error('Authentication aborted.')); - }; - const closeServer = () => { - if (server.listening) { - server.close(); - server.closeIdleConnections?.(); - const closeConnectionsTimer = setTimeout(() => { - server.closeAllConnections?.(); - }, 100); - closeConnectionsTimer.unref?.(); - } - if (signal) { - signal.removeEventListener('abort', onAbort); + if (!response.ok) { + const error = await readOAuthError(response); + if (response.status === 404 || response.status === 501) { + throw new OAuthUnavailableError(`OAuth device authorization is not available for this endpoint: ${error.message}`); } - }; - - if (signal?.aborted) { - onAbort(); + throw new Error(`OAuth device authorization failed (${response.status}): ${error.message}`); } - if (signal) { - signal.addEventListener('abort', onAbort, { once: true }); + const payload = await response.json() as Partial; + if (!payload.device_code || !payload.user_code || !payload.verification_uri || !payload.verification_uri_complete + || typeof payload.expires_in !== 'number' || typeof payload.interval !== 'number') { + throw new Error('OAuth device authorization endpoint returned an invalid response.'); } - - const address = server.address(); - if (!address || typeof address === 'string') { - settled = true; - closeServer(); - throw new Error('Unable to determine local OAuth callback port.'); - } - const redirectUri = `http://${OAUTH_LOOPBACK_HOST}:${address.port}${OAUTH_CALLBACK_PATH}`; - - return { - redirectUri, - waitForCode() { - return waitForCode; - }, - close() { - if (!settled) { - settled = true; - rejectAuthorization(new Error('Authentication interrupted.')); - } - closeServer(); - }, - }; + return payload as OAuthDeviceAuthorizationResponse; } -function buildAuthorizeUrl( - metadata: OAuthAuthorizationServerMetadata, - input: { - clientId: string; - redirectUri: string; - resource: string; - scope: string; - state: string; - challenge: string; - projectId?: string; - }, -): string { - const url = new URL(metadata.authorization_endpoint); - url.searchParams.set('response_type', 'code'); - url.searchParams.set('client_id', input.clientId); - url.searchParams.set('redirect_uri', input.redirectUri); - url.searchParams.set('resource', input.resource); - url.searchParams.set('scope', input.scope); - url.searchParams.set('state', input.state); - url.searchParams.set('code_challenge', input.challenge); - url.searchParams.set('code_challenge_method', 'S256'); - if (input.projectId) { - url.searchParams.set('project_id', input.projectId); +function buildDeviceVerificationUrl(profile: OAuthProfile, device: OAuthDeviceAuthorizationResponse): string { + if (!profile.config_url) { + return device.verification_uri_complete; } - return url.toString(); + + const configUrl = new URL(profile.config_url); + const verificationUrl = new URL('/oauth/device', configUrl.origin); + verificationUrl.searchParams.set('user_code', device.user_code); + return verificationUrl.toString(); } -async function exchangeAuthorizationCode( +async function pollDeviceToken( metadata: OAuthAuthorizationServerMetadata, clientId: string, - code: string, - verifier: string, - redirectUri: string, - resource: string, + device: OAuthDeviceAuthorizationResponse, + signal?: AbortSignal, ): Promise { - const body = new URLSearchParams({ - grant_type: 'authorization_code', - code, - redirect_uri: redirectUri, - client_id: clientId, - resource, - code_verifier: verifier, - }); - return exchangeToken(metadata.token_endpoint, body); + const expiresAt = Date.now() + device.expires_in * 1000; + let intervalMs = Math.max(device.interval, 1) * 1000; + + while (Date.now() < expiresAt) { + throwIfAborted(signal); + const body = new URLSearchParams({ + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + device_code: device.device_code, + client_id: clientId, + }); + const response = await fetch(metadata.token_endpoint, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: body.toString(), + }); + + if (response.ok) { + const payload = await response.json() as Partial; + if (!payload.access_token || !payload.token_type || typeof payload.expires_in !== 'number') { + throw new Error('OAuth token endpoint returned an invalid response.'); + } + return payload as OAuthTokenResponse; + } + + const error = await readOAuthError(response); + if (response.status === 400 && error.error === 'authorization_pending') { + await delay(intervalMs, signal); + continue; + } + if (response.status === 400 && error.error === 'slow_down') { + intervalMs += 5000; + await delay(intervalMs, signal); + continue; + } + if (response.status === 400 && error.error === 'access_denied') { + throw new Error('OAuth device authorization was denied.'); + } + if (response.status === 400 && error.error === 'expired_token') { + throw new Error('OAuth device authorization expired.'); + } + throw new Error(`OAuth token exchange failed (${response.status}): ${error.message}`); + } + + throw new Error('OAuth device authorization expired.'); } async function exchangeRefreshToken( @@ -438,22 +353,53 @@ async function exchangeToken(endpoint: string, body: URLSearchParams): Promise { + return (await readOAuthError(response)).message; +} + +async function readOAuthError(response: Response): Promise<{ error?: string; message: string }> { const text = await response.text(); if (!text) { - return response.statusText || 'Unknown error'; + return { message: response.statusText || 'Unknown error' }; } try { const parsed = JSON.parse(text) as Record; + const error = typeof parsed.error === 'string' ? parsed.error : undefined; if (typeof parsed.error_description === 'string') { - return parsed.error_description; + return { error, message: parsed.error_description }; } - if (typeof parsed.error === 'string') { - return parsed.error; + if (error) { + return { error, message: error }; } } catch { // Ignore non-JSON error responses. } - return text; + return { message: text }; +} + +function throwIfAborted(signal?: AbortSignal): void { + if (signal?.aborted) { + throw new Error('Authentication aborted.'); + } +} + +async function delay(milliseconds: number, signal?: AbortSignal): Promise { + throwIfAborted(signal); + await new Promise((resolve, reject) => { + let timeout: ReturnType; + const onAbort = () => { + clearTimeout(timeout); + signal?.removeEventListener('abort', onAbort); + reject(new Error('Authentication aborted.')); + }; + const cleanup = () => { + signal?.removeEventListener('abort', onAbort); + }; + timeout = setTimeout(() => { + cleanup(); + resolve(); + }, milliseconds); + signal?.addEventListener('abort', onAbort, { once: true }); + }); } function buildConfigResult( diff --git a/packages/client/src/OAuthServerApi.ts b/packages/client/src/OAuthServerApi.ts index 81f41d2b2..f1a141ae0 100644 --- a/packages/client/src/OAuthServerApi.ts +++ b/packages/client/src/OAuthServerApi.ts @@ -4,6 +4,8 @@ import type { CreateOAuthAuthorizationRequestPayload, OAuthAuthorizationDecisionResponse, OAuthAuthorizationRequest, + OAuthDeviceAuthorizationRequest, + OAuthDeviceAuthorizationResponse, } from '@vertesia/common'; export default class OAuthServerApi extends ClientBase { @@ -30,6 +32,14 @@ export default class OAuthServerApi extends ClientBase { return this.post('/requests', { payload }); } + createDeviceAuthorization(payload: OAuthDeviceAuthorizationRequest): Promise { + return this.post('/device_authorization', { payload }); + } + + retrieveDeviceRequest(userCode: string): Promise { + return this.get(`/device/${encodeURIComponent(userCode)}`); + } + retrieveRequest(requestId: string): Promise { return this.get(`/requests/${requestId}`); } diff --git a/packages/common/src/oauth-server.ts b/packages/common/src/oauth-server.ts index 08b7c9e1f..bcad14e10 100644 --- a/packages/common/src/oauth-server.ts +++ b/packages/common/src/oauth-server.ts @@ -6,7 +6,7 @@ export type OAuthClientStatus = 'active' | 'disabled'; export type OAuthRegistrationSource = 'admin' | 'dynamic'; export type OAuthProjectBindingMode = 'user_select' | 'fixed'; export type OAuthTokenEndpointAuthMethod = 'none' | 'client_secret_post' | 'client_secret_basic'; -export type OAuthGrantType = 'authorization_code' | 'refresh_token'; +export type OAuthGrantType = 'authorization_code' | 'refresh_token' | 'urn:ietf:params:oauth:grant-type:device_code'; export type OAuthResponseType = 'code'; export type OAuthAuthorizationRequestStatus = 'pending' | 'denied' | 'consumed'; export type OAuthClientRegistrationMode = 'registered' | 'client_id_metadata_document'; From 012872ffb9bb379cefffbf152d1b83c1c58b693f Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Sat, 25 Apr 2026 20:54:47 +0900 Subject: [PATCH 27/75] feat: add OAuthAccess principal type and update access token payload type reference --- packages/common/src/apikey.ts | 1 + packages/common/src/oauth-server.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/common/src/apikey.ts b/packages/common/src/apikey.ts index ae640a251..d6c91eb45 100644 --- a/packages/common/src/apikey.ts +++ b/packages/common/src/apikey.ts @@ -110,6 +110,7 @@ export interface AuthTokenPayload { export enum PrincipalType { User = "user", + OAuthAccess = "oauth_access", Group = "group", ApiKey = "apikey", ServiceAccount = "service_account", diff --git a/packages/common/src/oauth-server.ts b/packages/common/src/oauth-server.ts index bcad14e10..e3aedd0e8 100644 --- a/packages/common/src/oauth-server.ts +++ b/packages/common/src/oauth-server.ts @@ -1,4 +1,4 @@ -import type { AuthTokenPayload } from './apikey.js'; +import { PrincipalType, type AuthTokenPayload } from './apikey.js'; import type { ProjectRef } from './project.js'; export type OAuthClientType = 'public' | 'confidential'; @@ -263,7 +263,7 @@ export interface OAuthConsentRecord { } export interface OAuthAccessTokenPayload extends Omit { - type: 'oauth_access'; + type: PrincipalType.OAuthAccess; client_id: string; scope: string; user_id: string; From dc218f02d8989ad83271398ddf8e92a6490ca662 Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Mon, 27 Apr 2026 01:38:44 +0900 Subject: [PATCH 28/75] fix: preserve process runs in agent list response --- packages/client/src/store/AgentsApi.ts | 8 ++++++-- packages/common/src/store/agent-run.ts | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/client/src/store/AgentsApi.ts b/packages/client/src/store/AgentsApi.ts index 579ba8567..cea40ed3f 100644 --- a/packages/client/src/store/AgentsApi.ts +++ b/packages/client/src/store/AgentsApi.ts @@ -111,10 +111,10 @@ export class AgentsApi extends ApiTopic { } async listProcessRuns(query?: Omit): Promise { - const response = await this.get('/', { + const response: ListAgentRunsResponse = await this.get('/', { query: this.buildListQueryParams({ ...query, run_kind: 'process' }), }); - return response.items as unknown as ProcessRun[]; + return response.items.filter(isProcessRunResponse); } private buildListQueryParams(query?: ListAgentRunsQuery): Record { @@ -852,3 +852,7 @@ export class AgentsApi extends ApiTopic { } } + +function isProcessRunResponse(run: AgentRunResponse): run is ProcessRun { + return run.run_kind === 'process'; +} diff --git a/packages/common/src/store/agent-run.ts b/packages/common/src/store/agent-run.ts index 1c7060e38..14132df5d 100644 --- a/packages/common/src/store/agent-run.ts +++ b/packages/common/src/store/agent-run.ts @@ -422,7 +422,7 @@ export interface ListAgentRunsQuery { } export interface ListAgentRunsResponse { - items: AgentRun[]; + items: AgentRunResponse[]; total_count: number; next_cursor: string | null; } From e2cbed53cf6cdcd2da1b020bfacf9b64ef2a9abb Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Mon, 27 Apr 2026 15:48:28 +0900 Subject: [PATCH 29/75] chore: update subproject commit reference in llumiverse --- llumiverse | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/llumiverse b/llumiverse index aec993efc..fb4d35998 160000 --- a/llumiverse +++ b/llumiverse @@ -1 +1 @@ -Subproject commit aec993efc836524e951373a83a510a8857c79e84 +Subproject commit fb4d35998ace3962b7b1a0309efe42229b1e6999 From f81c3701d117714aeec855db66fe4081e5a0f8ae Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Tue, 28 Apr 2026 04:05:46 +0900 Subject: [PATCH 30/75] refactor: remove iterative generation activities and related types - Deleted generateToc, index, iterativeGenerationWorkflow, types, utils, and memory files. - Updated workflows to remove references to iterative generation. - Cleaned up pnpm workspace configuration to exclude memory packages. - Refactored PluginSidebar to handle AgentRunResponse and map it to WorkflowRun. --- packages/cli/package.json | 2 - packages/cli/src/index.ts | 11 - packages/cli/src/memory/index.ts | 22 -- packages/workflow/README.md | 3 +- packages/workflow/package.json | 1 - packages/workflow/src/index.ts | 3 - .../activities/extractToc.ts | 63 ------ .../activities/finalizeOutput.ts | 100 --------- .../activities/generatePart.ts | 123 ----------- .../activities/generateToc.ts | 116 ---------- .../iterative-generation/activities/index.ts | 4 - .../iterativeGenerationWorkflow.ts | 68 ------ .../src/iterative-generation/types.ts | 99 --------- .../src/iterative-generation/utils.ts | 126 ----------- packages/workflow/src/utils/memory.ts | 61 ------ packages/workflow/src/workflows.ts | 1 - packages/workflow/tsconfig.json | 3 +- pnpm-lock.yaml | 206 ------------------ pnpm-workspace.yaml | 3 + .../plugin-template/src/ui/PluginSidebar.tsx | 28 ++- 20 files changed, 25 insertions(+), 1018 deletions(-) delete mode 100644 packages/cli/src/memory/index.ts delete mode 100644 packages/workflow/src/iterative-generation/activities/extractToc.ts delete mode 100644 packages/workflow/src/iterative-generation/activities/finalizeOutput.ts delete mode 100644 packages/workflow/src/iterative-generation/activities/generatePart.ts delete mode 100644 packages/workflow/src/iterative-generation/activities/generateToc.ts delete mode 100644 packages/workflow/src/iterative-generation/activities/index.ts delete mode 100644 packages/workflow/src/iterative-generation/iterativeGenerationWorkflow.ts delete mode 100644 packages/workflow/src/iterative-generation/types.ts delete mode 100644 packages/workflow/src/iterative-generation/utils.ts delete mode 100644 packages/workflow/src/utils/memory.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 7f93eb75f..263b4a0f9 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -35,8 +35,6 @@ "@llumiverse/common": "workspace:*", "@vertesia/client": "workspace:*", "@vertesia/common": "workspace:*", - "@vertesia/memory-cli": "workspace:*", - "@vertesia/memory-commands": "workspace:*", "@vertesia/workflow": "workspace:*", "ansi-colors": "^4.1.3", "ansi-escapes": "^6.2.0", diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index fc7be2b8c..ee07e60ff 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,4 +1,3 @@ -import { setupMemoCommand } from '@vertesia/memory-cli'; import { Command } from 'commander'; import { registerAppsCommand } from './apps/index.js'; import { registerAgentsCommand } from './agents/index.js'; @@ -6,7 +5,6 @@ import { registerArtifactsCommand } from './artifacts/index.js'; import { registerDataCommand } from './data/index.js'; import { listEnvironments } from './envs/index.js'; import { listInteractions } from './interactions/index.js'; -import { getPublishMemoryAction } from './memory/index.js'; import { registerObjectsCommand } from './objects/index.js'; import { getVersion, upgrade } from './package.js'; import { createProfile, deleteProfile, listProfiles, loginProfile, logoutProfile, showActiveAuthToken, showActiveIdToken, showProfile, tryRefreshToken, updateCurrentProfile, updateProfile, useProfile, type CreateProfileOptions } from './profiles/commands.js'; @@ -121,15 +119,6 @@ program.command("runs [interactionId]") runHistory(program, interactionId, options); }); -const memoCmd = program.command("memo") - .description("Build and export memory packs (deprecated)"); -setupMemoCommand(memoCmd, getPublishMemoryAction(program)); -for (const command of memoCmd.commands) { - command.hook('preAction', () => { - console.warn('Warning: `vertesia memo` is deprecated and will be removed in a future release.'); - }); -} - registerWorkerCommand(program); registerAppsCommand(program); registerAgentsCommand(program); diff --git a/packages/cli/src/memory/index.ts b/packages/cli/src/memory/index.ts deleted file mode 100644 index 8d5a8bffc..000000000 --- a/packages/cli/src/memory/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { VertesiaClient } from "@vertesia/client"; -import { NodeStreamSource } from "@vertesia/client/node"; -import { Command } from "commander"; -import { createReadStream } from "fs"; -import { getClient } from "../client.js"; - -export function getPublishMemoryAction(program: Command) { - return async (file: string, name: string) => { - const client = await getClient(program); - return publishMemory(client, file, name); - }; -} - -async function publishMemory(client: VertesiaClient, file: string, name: string) { - const stream = createReadStream(file); - const path = await client.files.uploadMemoryPack(new NodeStreamSource( - stream, - name, - "application/gzip", - )); - return path; -} diff --git a/packages/workflow/README.md b/packages/workflow/README.md index 7cc55ae3f..0c7f04829 100644 --- a/packages/workflow/README.md +++ b/packages/workflow/README.md @@ -25,7 +25,7 @@ import { ... } from "@vertesia/workflow/activities"; import { ... } from "@vertesia/workflow/dsl-activities"; // Workflow definitions for Temporal -import { dslWorkflow, iterativeGenerationWorkflow } from "@vertesia/workflow/workflows"; +import { dslWorkflow } from "@vertesia/workflow/workflows"; // Pre-bundled workflows for Temporal workers import { ... } from "@vertesia/workflow/workflows-bundle"; @@ -57,7 +57,6 @@ The package includes activities for document processing: Pre-built workflows for common patterns: - **dslWorkflow** - Execute DSL-defined workflow pipelines -- **iterativeGenerationWorkflow** - Iterative content generation with refinement - **recalculateEmbeddingsWorkflow** - Bulk embedding recalculation ## License diff --git a/packages/workflow/package.json b/packages/workflow/package.json index 9ad77cf45..b30d6bebc 100644 --- a/packages/workflow/package.json +++ b/packages/workflow/package.json @@ -39,7 +39,6 @@ "@vertesia/api-fetch-client": "workspace:*", "@vertesia/client": "workspace:*", "@vertesia/common": "workspace:*", - "@vertesia/memory": "workspace:*", "fast-deep-equal": "^3.1.3", "jsonwebtoken": "^9.0.3", "mime": "^4.0.0", diff --git a/packages/workflow/src/index.ts b/packages/workflow/src/index.ts index 96b015c73..19de0458b 100644 --- a/packages/workflow/src/index.ts +++ b/packages/workflow/src/index.ts @@ -8,7 +8,6 @@ //TODO remove this - it is only for backward compat - iot is used from old workflows export { dslWorkflow } from "./dsl/dsl-workflow.js"; -export * from "./iterative-generation/iterativeGenerationWorkflow.js"; export * from "./activities/advanced/createDocumentTypeFromInteractionRun.js"; export * from "./activities/advanced/createOrUpdateDocumentFromInteractionRun.js"; @@ -26,14 +25,12 @@ export * from "./activities/rateLimiter.js"; export * from "./activities/renditions/generateImageRendition.js"; export * from "./activities/renditions/generateVideoRendition.js"; export * from "./activities/setDocumentStatus.js"; -export * from "./iterative-generation/activities/index.js"; export * from "./dsl/setup/ActivityContext.js"; export * from "./errors.js"; export * from "./result-types.js"; export * from "./utils/blobs.js"; export * from "./utils/client.js"; -export * from "./utils/memory.js"; export * from "./utils/renditions.js"; export * from "./utils/storage.js"; export * from "./utils/text-preview-utils.js"; diff --git a/packages/workflow/src/iterative-generation/activities/extractToc.ts b/packages/workflow/src/iterative-generation/activities/extractToc.ts deleted file mode 100644 index 2c6c583df..000000000 --- a/packages/workflow/src/iterative-generation/activities/extractToc.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { log } from "@temporalio/activity"; -import { WorkflowExecutionPayload } from "@vertesia/common"; -import { parse as parseYaml } from "yaml"; -import { getVertesiaClient } from "../../utils/client.js"; -import { - buildAndPublishMemoryPack, - loadMemoryPack, -} from "../../utils/memory.js"; -import { - IterativeGenerationPayload, - OutputMemoryMeta, - Toc, - TocIndex, -} from "../types.js"; -import { tocIndex } from "../utils.js"; - -/** - * This activity is called if the toc was provided in the payload. Otherwise - * the generateToc is called. - * - * @param payload - */ -export async function it_gen_extractToc( - payload: WorkflowExecutionPayload, -): Promise { - const vars = payload.vars as IterativeGenerationPayload; - const memory = vars.memory; - const client = await getVertesiaClient(payload); - - const inMemory = await loadMemoryPack(client, `${memory}/input`); - let tocJson: string | null = null; - let tocYaml: string | null = null; - let toc: any; - tocJson = await inMemory.getEntryText("toc.json"); - if (!tocJson) { - tocYaml = await inMemory.getEntryText("toc.yaml"); - if (tocYaml) { - toc = parseYaml(tocYaml) as Toc; - } - } else { - toc = JSON.parse(tocJson) as Toc; - } - if (!toc) { - log.info(`Nothing to extract: no TOC found in the input memory pack.`); - return null; // no toc found - } - - log.info(`Found a TOC in the input memory pack`); - - await buildAndPublishMemoryPack( - client, - `${vars.memory}/output`, - async () => { - return { - toc, - lastProcessedPart: undefined, // the part index (a number array) - previouslyGenerated: "", - } as OutputMemoryMeta; - }, - ); - - return tocIndex(toc); -} diff --git a/packages/workflow/src/iterative-generation/activities/finalizeOutput.ts b/packages/workflow/src/iterative-generation/activities/finalizeOutput.ts deleted file mode 100644 index 1b46b520c..000000000 --- a/packages/workflow/src/iterative-generation/activities/finalizeOutput.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { log } from "@temporalio/activity"; -import { WorkflowExecutionPayload } from "@vertesia/common"; -import { getVertesiaClient } from "../../utils/client.js"; -import { expandVars } from "../../utils/expand-vars.js"; -import { - buildAndPublishMemoryPack, - loadMemoryPack, -} from "../../utils/memory.js"; -import { - IterativeGenerationPayload, - Section, - SECTION_ID_PLACEHOLDER, - TocSection, -} from "../types.js"; - -export async function it_gen_finalizeOutput( - payload: WorkflowExecutionPayload, -): Promise { - const vars = payload.vars as IterativeGenerationPayload; - - const memory = vars.memory; - const client = await getVertesiaClient(payload); - const inMemory = await loadMemoryPack(client, `${memory}/input`); - const outMemory = await loadMemoryPack(client, `${memory}/output`); - - const content = await outMemory.getEntryText("content.json"); - if (!content) { - log.info(`Nothing to do. No content.json file found`); - return "No content.json file found"; - } - - log.info(`Creating the final output memory pack.`); - - const inMeta = await inMemory.getMetadata(); - let tocName = "toc.json"; - let toc = await inMemory.getEntryText(tocName); - if (!toc) { - tocName = "toc.yaml"; - toc = await inMemory.getEntryText(tocName); - } - if (!toc) { - const outMeta = await outMemory.getMetadata(); - tocName = "toc.json"; - toc = JSON.stringify(outMeta.toc); - } - const sections = JSON.parse(content) as Section[]; - - await buildAndPublishMemoryPack( - client, - `${memory}/output`, - async ({ copyText }) => { - // copy the input toc file if any - if (toc) { - copyText(toc, tocName); - } - // copy the raw JSON content - copyText(content, "content.json"); - if (vars.section_file_pattern) { - log.info( - `Saving sections to files using pattern: ${vars.section_file_pattern}`, - ); - // save sections to files - for (const section of sections) { - let content = section.content; - if (vars.section_file_header) { - content = - getSectionFileHeader( - section, - vars.section_file_header, - ) + - "\n\n" + - content; - } - copyText( - content, - getSectionFileName(section, vars.section_file_pattern), - ); - } - } - return { - ...inMeta, - vars, - }; - }, - ); - - return `Processing done. Extracted files to: ${vars.section_file_pattern}`; -} - -function getSectionFileHeader(section: TocSection, header: string): string { - const date = new Date().toISOString(); - return expandVars(header, { - section, - date, - }); -} - -function getSectionFileName(section: TocSection, pattern: string): string { - return pattern.replace(SECTION_ID_PLACEHOLDER, section.id); -} diff --git a/packages/workflow/src/iterative-generation/activities/generatePart.ts b/packages/workflow/src/iterative-generation/activities/generatePart.ts deleted file mode 100644 index f36c2e5f3..000000000 --- a/packages/workflow/src/iterative-generation/activities/generatePart.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { ApplicationFailure } from "@temporalio/workflow"; -import { WorkflowExecutionPayload } from "@vertesia/common"; -import { MemoryPack } from "@vertesia/memory"; -import { getVertesiaClient } from "../../utils/client.js"; -import { - buildAndPublishMemoryPack, - loadMemoryPack, -} from "../../utils/memory.js"; -import { - IterativeGenerationPayload, - OutputMemoryMeta, - Section, - TocPart, - TocSection, -} from "../types.js"; -import { executeWithVars, expectMemoryIsConsistent } from "../utils.js"; - -export async function it_gen_generatePart( - payload: WorkflowExecutionPayload, - path: number[], -) { - const vars = payload.vars as IterativeGenerationPayload; - const client = await getVertesiaClient(payload); - const memory = vars.memory; - - const [sectionIndex, partIndex] = path; - const outMemory = await loadMemoryPack(client, `${memory}/output`); - const meta = (await outMemory.getMetadata()) as OutputMemoryMeta; - - // the section we build is the section at the given index - const section: TocSection = meta.toc.sections[sectionIndex]; - if (!section) { - throw ApplicationFailure.nonRetryable( - "Section not found in the TOC", - "SectionNotFound", - { memory, path }, - ); - } - let part: TocPart | undefined; - if (partIndex !== undefined) { - part = section.parts?.[partIndex]; - if (!part) { - throw ApplicationFailure.nonRetryable( - "Part not found in the TOC section", - "PartNotFound", - { memory, path }, - ); - } - } - - expectMemoryIsConsistent(meta, path); - - const content = await loadGeneratedContent(outMemory); - - let previously_generated = getPreviouslyGeneratedContent( - content, - !part, - vars.remembrance_strategy, - ); - - if (!part) { - // a new section - content.push({ - id: section.id, - name: section.name, - description: section.description, - content: "", - }); - } else if (!content.length) { - throw ApplicationFailure.nonRetryable( - "content.json is empty while generating a part", - "InvalidIterationState", - { memory, path }, - ); - } - - const interaction = vars.iterative_interaction || vars.interaction; - const r = await executeWithVars(client, interaction, vars, { - iteration: { - toc: meta.toc, - previously_generated, - section: section.name, - part: part?.name, - path: path, - }, - }); - - const result = r.result.text(); - content[content.length - 1].content += result; - meta.lastProcessedPart = path; - await buildAndPublishMemoryPack( - client, - `${memory}/output`, - async ({ copyText }) => { - copyText(JSON.stringify(content, null, 2), "content.json"); - return meta; - }, - ); -} - -async function loadGeneratedContent(memory: MemoryPack): Promise { - const content = await memory.getEntryText("content.json"); - return content ? JSON.parse(content) : []; -} - -function getPreviouslyGeneratedContent( - sections: Section[], - isNewSection: boolean, - strategy?: "document" | "section" | "none", -): string { - switch (strategy) { - case "document": - return sections - .map((section: Section) => section.content || "") - .join("\n\n"); - case "none": - return ""; - default: - return isNewSection - ? "" - : sections[sections.length - 1]?.content || ""; - } -} diff --git a/packages/workflow/src/iterative-generation/activities/generateToc.ts b/packages/workflow/src/iterative-generation/activities/generateToc.ts deleted file mode 100644 index 8247aa0ad..000000000 --- a/packages/workflow/src/iterative-generation/activities/generateToc.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { WorkflowExecutionPayload } from "@vertesia/common"; -import { getVertesiaClient } from "../../utils/client.js"; -import { buildAndPublishMemoryPack } from "../../utils/memory.js"; -import { - IterativeGenerationPayload, - OutputMemoryMeta, - Toc, - TocIndex, -} from "../types.js"; -import { executeWithVars, tocIndex } from "../utils.js"; - -const defaultTocSchema = { - type: "object", - properties: { - sections: { - type: "array", - items: { - type: "object", - properties: { - id: { - type: "string", - description: - "the id of the section, can be a filename if working on a file, a slug if working on a document or path, or a unique identifier if working on a model.", - }, - operation: { - type: "string", - enum: ["create", "update", "delete"], - description: - "The operation to perform on the section, create, update or delete. If update, you will be requested later to provide the list of change operation to perform.", - }, - name: { - type: "string", - description: - "The name or title of the section, should be the path in the OpenAPI spec, of the title of the section/part.", - }, - description: { - type: "string", - }, - instructions: { - type: "string", - }, - parts: { - type: "array", - description: - "when the section is too large, you can split it into parts, each part should have a title and description. Use it to split the section into subsection. When doing an API documentation, you can do one part for each path. When generating code, you can do one part for each method. When generating an OpenAPI spec, you can do one part for each operation.", - items: { - type: "object", - properties: { - id: { - type: "string", - description: - "the id of the part, can be a filename if working on a file, a slug if working on a document or path, or a unique identifier if working on a model.", - }, - name: { - type: "string", - description: - "The name or title of the part, should be the path in the OpenAPI spec, of the title of the section/part.", - }, - /* - "description": { - "type": "string" - }, - */ - instructions: { - type: "string", - }, - }, - required: ["id", "name"], - }, - }, - }, - required: ["id", "name", "operation"], - }, - }, - }, - required: ["sections"], -}; - -export async function it_gen_generateToc( - payload: WorkflowExecutionPayload, -): Promise { - const vars = payload.vars as IterativeGenerationPayload; - - const schema = vars.toc_schema || defaultTocSchema; - - const client = await getVertesiaClient(payload); - - const run = await executeWithVars( - client, - vars.interaction, - vars, - undefined, - schema, - ); - - //Parse the CompletionResult[] to get a Toc object - const jsonResults = run.result.object(); - - const toc: Toc = { - sections: jsonResults.sections - }; - - await buildAndPublishMemoryPack( - client, - `${vars.memory}/output`, - async () => { - return { - toc, - lastProcessedPart: undefined, // the part index (a number array) - previouslyGenerated: "", - } satisfies OutputMemoryMeta; - }, - ); - - return tocIndex(toc); -} diff --git a/packages/workflow/src/iterative-generation/activities/index.ts b/packages/workflow/src/iterative-generation/activities/index.ts deleted file mode 100644 index 36d4b1114..000000000 --- a/packages/workflow/src/iterative-generation/activities/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { it_gen_generateToc } from './generateToc.js'; -export { it_gen_generatePart } from './generatePart.js'; -export { it_gen_extractToc } from './extractToc.js'; -export { it_gen_finalizeOutput } from './finalizeOutput.js' \ No newline at end of file diff --git a/packages/workflow/src/iterative-generation/iterativeGenerationWorkflow.ts b/packages/workflow/src/iterative-generation/iterativeGenerationWorkflow.ts deleted file mode 100644 index 511a41ee7..000000000 --- a/packages/workflow/src/iterative-generation/iterativeGenerationWorkflow.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { WorkflowExecutionPayload } from "@vertesia/common"; - -import { log, proxyActivities } from "@temporalio/workflow"; -import { WF_NON_RETRYABLE_ERRORS } from "../errors.js"; -import * as activities from "./activities/index.js"; -import { IterativeGenerationPayload, PartIndex, SECTION_ID_PLACEHOLDER } from "./types.js"; - -const { - it_gen_extractToc, - it_gen_generateToc, - it_gen_generatePart, - it_gen_finalizeOutput -} = proxyActivities({ - startToCloseTimeout: "15 minute", - retry: { - initialInterval: '30s', - backoffCoefficient: 2, - maximumAttempts: 20, - maximumInterval: 100 * 30 * 1000, //ms - nonRetryableErrorTypes: WF_NON_RETRYABLE_ERRORS, - }, -}); - -export async function iterativeGenerationWorkflow(payload: WorkflowExecutionPayload) { - log.info(`Executing Iterative generation workflow.`); - - const vars = payload.vars as IterativeGenerationPayload; - if (vars.section_file_pattern && !vars.section_file_pattern.includes(SECTION_ID_PLACEHOLDER)) { - throw new Error(`Invalid section_file_pattern: ${vars.section_file_pattern}. It must include the ${SECTION_ID_PLACEHOLDER} placeholder.`); - } - - // extractToc tries to extract the toc from the input memory pack (toc.json or toc.yaml) - // the generateToc activity is returning the toc hierarchy. - // It doesn't include extra TOC details like description etc. - // To minimize the payload size only the hierarchy and the section/part names are returned - let toc = await it_gen_extractToc(payload); - if (!toc) { - log.info(`No TOC was specified in the input memory pack. Generating one.`); - toc = await it_gen_generateToc(payload); - } else { - log.info(`Using the TOC specified in the input memory pack.`); - } - - if (toc.sections.length === 0) { - //TODO how to handle this case? - throw new Error("Nothing to generate: TOC is empty"); - } - - for (const section of toc.sections) { - log.info(`Generating section: ${formatPath(section)}`); - await it_gen_generatePart(payload, section.path); - - if (section.parts) { - for (const part of section.parts) { - log.info(`Generating part: ${formatPath(part)}`); - await it_gen_generatePart(payload, part.path); - } - } - } - - log.info(`Post-processing output memory pack`); - await it_gen_finalizeOutput(payload); -} - -function formatPath(node: PartIndex) { - // we print 1 based indexes - return node.path.map(i => i + 1).join('.') + ' ' + node.name; -} \ No newline at end of file diff --git a/packages/workflow/src/iterative-generation/types.ts b/packages/workflow/src/iterative-generation/types.ts deleted file mode 100644 index 2cd4d4cbf..000000000 --- a/packages/workflow/src/iterative-generation/types.ts +++ /dev/null @@ -1,99 +0,0 @@ - -export const SECTION_ID_PLACEHOLDER = '%id'; - -/** - * An iterative generation workflow uses 2 memory packs one for input and the other for output. - * The input memory packs must be available in the project blobs bucket at `${tenant_id/memories/${memory_name}/input.tar.gz`. - * The output memory pack will be generated at `${tenant_id/memories/${memory_name}/output.tar.gz`. - * Each iteration is overwriting the output memory pack with the new generated content. - * The complete name of the input and output memory packs are: "${name}/input" and "${name}/output" where name is the base memory name. - */ -export interface IterativeGenerationPayload { - // the main interaction to execute. If iterative_generation is defined - // the main interaction will only be used to prepare the iteration (to generate the TOC) - // otherwise it will be used for the iterative generation too. - interaction: string; - // if defined this will be used for the iterative interaction which will generate parts. - // otherwise the main interaction will be used for iterative generation. - iterative_interaction?: string; - // the environment to use - environment?: string; - // the model to use - model?: string; - // A custom max tokens - max_tokens?: number; - // A custom temperature - temperature?: number; - // the memory pack group name - memory: string; - // the input memory pack mapping - input_mapping?: Record; - // custom toc schema if any TODO remove this - toc_schema?: Record - /** - * If not set to "none" the previously generated content will be passed to the iteration. - * If not set at all defaults to "section". - * If "section" is used only the section content previously generated will be passed to the next iteration. - * If "document" is used the whole previously generated document content will be passed to the next iteration. - * Defaults to section. - */ - remembrance_strategy?: "document" | "section" | "none"; - /** - * If present will save sections in files using the pattern - * The pattern must include a placeholder for the section id: %id. - * Examples: `sections/%id.md`, `%id/page.mdx` etc. - * @see SECTION_ID_PLACEHOLDER - */ - section_file_pattern?: string; - /** - * An optional header to prepend to the section files. - * The header can contain the following variables: - * - ${section} - the section object - * - ${date} - the date when the file was generated - */ - section_file_header?: string; -} - -export interface TocPart { - id: string; - name: string; - description?: string; - instructions?: string; -} - -export interface TocSection { - id: string; - name: string; - description?: string; - instructions?: string; - parts?: TocPart[]; -} - -export interface Toc { - sections: TocSection[]; -} - - -export interface SectionIndex extends PartIndex { - parts?: PartIndex[]; -} -export interface PartIndex { - path: number[]; - name: string; -} -export interface TocIndex { - sections: SectionIndex[]; -} - -export interface OutputMemoryMeta { - toc: Toc; - previouslyGenerated: string; - lastProcessedPart?: number[] | undefined; -} - -export interface Section { - id: string; - name: string; - description?: string; - content: string; -} diff --git a/packages/workflow/src/iterative-generation/utils.ts b/packages/workflow/src/iterative-generation/utils.ts deleted file mode 100644 index 2893e08a9..000000000 --- a/packages/workflow/src/iterative-generation/utils.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { ModelOptions, TextFallbackOptions } from "@llumiverse/common"; -import { ApplicationFailure } from "@temporalio/workflow"; -import { VertesiaClient } from "@vertesia/client"; -import { OutputMemoryMeta, PartIndex, Toc, TocIndex, TocSection } from "./types.js"; - -//TODO: For whole file, support for options beyond max_tokens and temperature and multiple modalities. -export interface ExecuteOptions { - interaction: string; - memory: string; - memory_mapping?: Record; - environment?: string; - model?: string; - model_options?: ModelOptions; - result_schema?: Record; -} - -export async function execute(client: VertesiaClient, options: ExecuteOptions) { - return client.interactions.executeByName(options.interaction, { - data: { - ...options.memory_mapping, - "@memory": options.memory - }, - result_schema: options.result_schema, - config: { - environment: options.environment, - model: options.model, - model_options: options.model_options, - } - }); -} - -export function executeWithVars(client: VertesiaClient, interaction: string, vars: Record, mapping?: Record, result_schema?: Record) { - if (mapping) { - mapping = { ...vars.input_mapping, ...mapping }; - } else { - mapping = vars.input_mapping; - } - const model_options: TextFallbackOptions = { - _option_id: "text-fallback", - max_tokens: vars.max_tokens, - temperature: vars.temperature - } - return execute(client, { - interaction: interaction, - memory: `${vars.memory}/input`, - memory_mapping: mapping, - environment: vars.environment, - model: vars.model, - model_options: model_options, - result_schema: result_schema - }); -} - -export function isSamePartIndex(part1: number[], part2: number[]) { - return part1 && part2 && part1.length === part2.length && part1.every((v, i) => v === part2[i]); -} - -export function getPreviousPathIndex(toc: Toc, pathIndex: number[]): number[] | null { - let [sectionIdx, partIdx] = pathIndex; - if (partIdx === undefined) { - let prevSectionIdx = sectionIdx - 1; - if (prevSectionIdx < 0) { - return null; - } else { - const prevParts = toc.sections[prevSectionIdx].parts; - if (prevParts && prevParts.length > 0) { // return the last part of the previous section - return [prevSectionIdx, prevParts.length - 1]; - } else { // if no parts return the section itself - return [prevSectionIdx]; - } - } - } else if (partIdx > 0) { // return the previous part in the same section - return [sectionIdx, partIdx - 1]; - } else { // if the first part return the section itself - return [sectionIdx]; - } -} - - -export function expectMemoryIsConsistent(meta: OutputMemoryMeta, pathIndex: number[]) { - const metaLastProcessedPart = meta.lastProcessedPart; - if (!metaLastProcessedPart) { - if (pathIndex.length > 1 && pathIndex[0] !== 0) { - throw ApplicationFailure.nonRetryable('Memory last processed part is not consistent with the workflow.', 'MemoryPackNotConsistent', { currentIndex: pathIndex, expectedPreviousIndex: [pathIndex[0], pathIndex[1] - 1], previousIndex: metaLastProcessedPart }); - } else { - return; - } - } - const prevPathIndex = getPreviousPathIndex(meta.toc, pathIndex); - if (!prevPathIndex) { - throw ApplicationFailure.nonRetryable('Memory last processed part is not consistent with the workflow', 'MemoryPackNotConsistent', { currentIndex: pathIndex, expectedPreviousIndex: prevPathIndex, previousIndex: metaLastProcessedPart }); - } else if (!isSamePartIndex(prevPathIndex, metaLastProcessedPart)) { - throw ApplicationFailure.nonRetryable('Memory last processed part is not consistent with the workflow', 'MemoryPackNotConsistent', { currentIndex: pathIndex, expectedPreviousIndex: prevPathIndex, previousIndex: meta.lastProcessedPart }); - } -} - -export function sectionWithoutParts(section: TocSection) { - const clone = { ...section }; - delete clone.parts; - return clone; -} - -export function tocIndex(toc: Toc): TocIndex { - const index = { sections: [] } as TocIndex; - const sections = toc.sections; - for (let i = 0, l = sections.length; i < l; i++) { - const section = sections[i]; - const indexParts: PartIndex[] = []; - if (section.parts) { - const parts = section.parts; - for (let k = 0, ll = section.parts.length; k < ll; k++) { - const part = parts[k]; - indexParts.push({ - name: part.id, - path: [i, k] - }); - } - } - index.sections.push({ - name: section.id, - path: [i], - parts: indexParts - }); - } - return index; -} diff --git a/packages/workflow/src/utils/memory.ts b/packages/workflow/src/utils/memory.ts deleted file mode 100644 index 117bc8db9..000000000 --- a/packages/workflow/src/utils/memory.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { VertesiaClient } from "@vertesia/client"; -import { NodeStreamSource } from "@vertesia/client/node"; -import { Commands, MemoryPack, buildMemoryPack as _buildMemoryPack, loadMemoryPack as _loadMemoryPack } from "@vertesia/memory"; -import { createReadStream, createWriteStream } from "fs"; -import { rm } from "fs/promises"; -import { webStreamToReadable } from "node-web-stream-adapters"; -import { pipeline } from "stream/promises"; - -import tmp from "tmp"; -import zlib from "zlib"; - -tmp.setGracefulCleanup(); - - -export async function publishMemoryPack(client: VertesiaClient, file: string, name: string): Promise { - const stream = createReadStream(file); - try { - const source = new NodeStreamSource(stream, name); - await client.files.uploadMemoryPack(source); - } catch (err: any) { - stream.destroy(); - throw err; - } -} - -export async function buildMemoryPack(recipeFn: (commands: Commands) => Promise>): Promise { - const tarFile = tmp.fileSync({ - prefix: "composable-memory-pack-", - postfix: ".tar.gz", - }); - return await _buildMemoryPack(recipeFn, { - out: tarFile.name, - gzip: true, - }); -} - -export async function buildAndPublishMemoryPack(client: VertesiaClient, name: string, recipeFn: (commands: Commands) => Promise>): Promise { - const tarFile = await buildMemoryPack(recipeFn); - try { - await publishMemoryPack(client, tarFile, name); - } finally { - await rm(tarFile); - } -} - -export async function fetchMemoryPack(client: VertesiaClient, name: string): Promise { - const webStream = await client.files.downloadMemoryPack(name); - const tarFile = tmp.fileSync({ - prefix: "composable-memory-pack-", - postfix: ".tar", - discardDescriptor: true, - }); - const streamIn = webStreamToReadable(webStream); - const streamOut = createWriteStream(tarFile.name); - await pipeline(streamIn, zlib.createGunzip(), streamOut); - return tarFile.name; -} - -export function loadMemoryPack(client: VertesiaClient, name: string): Promise { - return fetchMemoryPack(client, name).then(file => _loadMemoryPack(file)); -} diff --git a/packages/workflow/src/workflows.ts b/packages/workflow/src/workflows.ts index e4ed66a18..862662697 100644 --- a/packages/workflow/src/workflows.ts +++ b/packages/workflow/src/workflows.ts @@ -2,6 +2,5 @@ * Export workflows to be registered on temporal workers */ export { dslWorkflow } from "./dsl/dsl-workflow.js"; -export { iterativeGenerationWorkflow } from "./iterative-generation/iterativeGenerationWorkflow.js"; export { notifyWebhookWorkflow } from "./system/notifyWebhookWorkflow.js"; export { recalculateEmbeddingsWorkflow } from "./system/recalculateEmbeddingsWorkflow.js"; diff --git a/packages/workflow/tsconfig.json b/packages/workflow/tsconfig.json index 583226a74..810144cac 100644 --- a/packages/workflow/tsconfig.json +++ b/packages/workflow/tsconfig.json @@ -49,7 +49,6 @@ } , { "path": "../../llumiverse/common/tsconfig.json" }, - { "path": "../api-fetch-client/tsconfig.json" }, - { "path": "../memory/tsconfig.json" } + { "path": "../api-fetch-client/tsconfig.json" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c7d3a434..801c0544c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -197,12 +197,6 @@ importers: '@vertesia/common': specifier: workspace:* version: link:../common - '@vertesia/memory-cli': - specifier: workspace:* - version: link:../memory-cli - '@vertesia/memory-commands': - specifier: workspace:* - version: link:../memory-commands '@vertesia/workflow': specifier: workspace:* version: link:../workflow @@ -529,108 +523,6 @@ importers: specifier: ^4.0.16 version: 4.0.18(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) - packages/memory: - dependencies: - '@vertesia/converters': - specifier: workspace:* - version: link:../converters - '@vertesia/json': - specifier: workspace:* - version: link:../json - glob: - specifier: ^11.1.0 - version: 11.1.0 - micromatch: - specifier: ^4.0.8 - version: 4.0.8 - tar-stream: - specifier: ^3.1.7 - version: 3.1.7 - devDependencies: - '@eslint/js': - specifier: ^10.0.1 - version: 10.0.1(eslint@10.0.2(jiti@2.6.1)) - '@types/micromatch': - specifier: ^4.0.9 - version: 4.0.10 - '@types/node': - specifier: ^25.6.0 - version: 25.6.0 - '@types/tar-stream': - specifier: ^3.1.3 - version: 3.1.4 - eslint: - specifier: ^10.0.2 - version: 10.0.2(jiti@2.6.1) - ts-dual-module: - specifier: ^0.6.3 - version: 0.6.3(patch_hash=fa2655a6470821a60e6e431a2f196a259c9fc67e51c0a71fc38f63017cc4213f) - typescript-eslint: - specifier: ^8.58.1 - version: 8.58.1(eslint@10.0.2(jiti@2.6.1))(typescript@6.0.3) - vitest: - specifier: ^4.0.16 - version: 4.0.18(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) - - packages/memory-cli: - dependencies: - '@vertesia/memory': - specifier: workspace:* - version: link:../memory - '@vertesia/memory-commands': - specifier: workspace:* - version: link:../memory-commands - commander: - specifier: ^14.0.2 - version: 14.0.3 - devDependencies: - '@eslint/js': - specifier: ^10.0.1 - version: 10.0.1(eslint@10.0.2(jiti@2.6.1)) - '@types/node': - specifier: ^25.6.0 - version: 25.6.0 - eslint: - specifier: ^10.0.2 - version: 10.0.2(jiti@2.6.1) - typescript: - specifier: ^6.0.2 - version: 6.0.3 - typescript-eslint: - specifier: ^8.58.1 - version: 8.58.1(eslint@10.0.2(jiti@2.6.1))(typescript@6.0.3) - vitest: - specifier: ^4.0.16 - version: 4.0.18(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) - - packages/memory-commands: - dependencies: - '@vertesia/memory': - specifier: workspace:* - version: link:../memory - typescript: - specifier: ^6.0.2 - version: 6.0.3 - devDependencies: - '@eslint/js': - specifier: ^10.0.1 - version: 10.0.1(eslint@10.0.2(jiti@2.6.1)) - '@types/node': - specifier: ^25.6.0 - version: 25.6.0 - eslint: - specifier: ^10.0.2 - version: 10.0.2(jiti@2.6.1) - ts-dual-module: - specifier: ^0.6.3 - version: 0.6.3(patch_hash=fa2655a6470821a60e6e431a2f196a259c9fc67e51c0a71fc38f63017cc4213f) - typescript-eslint: - specifier: ^8.58.1 - version: 8.58.1(eslint@10.0.2(jiti@2.6.1))(typescript@6.0.3) - vitest: - specifier: ^4.0.16 - version: 4.0.18(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.3) - packages/plugin-builder: dependencies: '@adobe/css-tools': @@ -1070,9 +962,6 @@ importers: '@vertesia/common': specifier: workspace:* version: link:../common - '@vertesia/memory': - specifier: workspace:* - version: link:../memory fast-deep-equal: specifier: ^3.1.3 version: 3.1.3 @@ -4033,9 +3922,6 @@ packages: '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} - '@types/braces@3.0.5': - resolution: {integrity: sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w==} - '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -4213,9 +4099,6 @@ packages: '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} - '@types/micromatch@4.0.10': - resolution: {integrity: sha512-5jOhFDElqr4DKTrTEbnW8DZ4Hz5LRUEmyrGpCMrD/NphYv3nUnaF08xmSLx1rGGnyEs/kFnhiw6dCgcDqMr5PQ==} - '@types/mocha@10.0.10': resolution: {integrity: sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==} @@ -4254,9 +4137,6 @@ packages: '@types/serve-static@2.2.0': resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} - '@types/tar-stream@3.1.4': - resolution: {integrity: sha512-921gW0+g29mCJX0fRvqeHzBlE/XclDaAG0Ousy1LCghsOhvaKacDeRGEVzQP9IPfKn8Vysy7FEXAIxycpc/CMg==} - '@types/tinycolor2@1.4.6': resolution: {integrity: sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==} @@ -4682,14 +4562,6 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} - b4a@1.7.5: - resolution: {integrity: sha512-iEsKNwDh1wiWTps1/hdkNdmBgDlDVZP5U57ZVOlt+dNFqpc/lpPouCIxZw+DYBgc4P9NDfIZMPNR4CHNhzwLIA==} - peerDependencies: - react-native-b4a: '*' - peerDependenciesMeta: - react-native-b4a: - optional: true - bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -4697,14 +4569,6 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - bare-events@2.8.2: - resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} - peerDependencies: - bare-abort-controller: '*' - peerDependenciesMeta: - bare-abort-controller: - optional: true - baseline-browser-mapping@2.9.19: resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} hasBin: true @@ -5527,9 +5391,6 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} - events-universal@1.0.1: - resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} - events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -5564,9 +5425,6 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fast-fifo@1.3.2: - resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} - fast-json-patch@3.1.1: resolution: {integrity: sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==} @@ -6690,10 +6548,6 @@ packages: micromark@4.0.2: resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -7465,9 +7319,6 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} - streamx@2.23.0: - resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==} - string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -7573,9 +7424,6 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} - tar-stream@3.1.7: - resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} - terser-webpack-plugin@5.3.16: resolution: {integrity: sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==} engines: {node: '>= 10.13.0'} @@ -7597,9 +7445,6 @@ packages: engines: {node: '>=10'} hasBin: true - text-decoder@1.2.7: - resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} - thingies@2.5.0: resolution: {integrity: sha512-s+2Bwztg6PhWUD7XMfeYm5qliDdSiZm7M7n8KjTkIsm3l/2lgVRc2/Gx/v+ZX8lT4FMA+i8aQvhcWylldc+ZNw==} engines: {node: '>=10.18'} @@ -11411,8 +11256,6 @@ snapshots: '@types/connect': 3.4.38 '@types/node': 25.6.0 - '@types/braces@3.0.5': {} - '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -11635,10 +11478,6 @@ snapshots: dependencies: '@types/unist': 3.0.3 - '@types/micromatch@4.0.10': - dependencies: - '@types/braces': 3.0.5 - '@types/mocha@10.0.10': {} '@types/ms@2.1.0': {} @@ -11679,10 +11518,6 @@ snapshots: '@types/http-errors': 2.0.5 '@types/node': 25.6.0 - '@types/tar-stream@3.1.4': - dependencies: - '@types/node': 25.6.0 - '@types/tinycolor2@1.4.6': {} '@types/tmp@0.2.6': {} @@ -12200,14 +12035,10 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 - b4a@1.7.5: {} - bail@2.0.2: {} balanced-match@4.0.4: {} - bare-events@2.8.2: {} - baseline-browser-mapping@2.9.19: {} binary-extensions@2.3.0: {} @@ -13207,12 +13038,6 @@ snapshots: event-target-shim@5.0.1: {} - events-universal@1.0.1: - dependencies: - bare-events: 2.8.2 - transitivePeerDependencies: - - bare-abort-controller - events@3.3.0: {} eventsource-parser@1.1.2: {} @@ -13248,8 +13073,6 @@ snapshots: fast-deep-equal@3.1.3: {} - fast-fifo@1.3.2: {} - fast-json-patch@3.1.1: {} fast-json-stable-stringify@2.1.0: {} @@ -14776,11 +14599,6 @@ snapshots: transitivePeerDependencies: - supports-color - micromatch@4.0.8: - dependencies: - braces: 3.0.3 - picomatch: 2.3.2 - mime-db@1.52.0: {} mime-types@2.1.35: @@ -15681,15 +15499,6 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 - streamx@2.23.0: - dependencies: - events-universal: 1.0.1 - fast-fifo: 1.3.2 - text-decoder: 1.2.7 - transitivePeerDependencies: - - bare-abort-controller - - react-native-b4a - string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -15814,15 +15623,6 @@ snapshots: tapable@2.3.0: {} - tar-stream@3.1.7: - dependencies: - b4a: 1.7.5 - fast-fifo: 1.3.2 - streamx: 2.23.0 - transitivePeerDependencies: - - bare-abort-controller - - react-native-b4a - terser-webpack-plugin@5.3.16(@swc/core@1.15.21(@swc/helpers@0.5.18))(webpack@5.105.2(@swc/core@1.15.21(@swc/helpers@0.5.18))): dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -15841,12 +15641,6 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 - text-decoder@1.2.7: - dependencies: - b4a: 1.7.5 - transitivePeerDependencies: - - react-native-b4a - thingies@2.5.0(tslib@2.8.1): dependencies: tslib: 2.8.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 44e7bd3ba..6e9a58213 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,9 @@ packages: - llumiverse/common - packages/* + - "!packages/memory" + - "!packages/memory-cli" + - "!packages/memory-commands" - templates/* - "!templates/worker-template" minimumReleaseAge: 4320 # 3 days in minutes, minimumReleaseAge protects against supply chain attacks. diff --git a/templates/plugin-template/src/ui/PluginSidebar.tsx b/templates/plugin-template/src/ui/PluginSidebar.tsx index a3b51e3aa..b548a80c1 100644 --- a/templates/plugin-template/src/ui/PluginSidebar.tsx +++ b/templates/plugin-template/src/ui/PluginSidebar.tsx @@ -5,10 +5,28 @@ import { SidebarSection, useSidebarToggle } from '@vertesia/ui/layout'; import { useLocation, useRouterBasePath } from '@vertesia/ui/router'; import { useUserSession } from '@vertesia/ui/session'; import { HomeIcon, MessageSquare, PlusCircle } from 'lucide-react'; -import type { WorkflowRun } from '@vertesia/common'; +import type { AgentRunResponse, WorkflowRun } from '@vertesia/common'; import { AppSidebarItem } from './AppSidebarItem'; import { ASSISTANT_INTERACTION } from './constants'; +function toWorkflowRun(run: AgentRunResponse): WorkflowRun { + const isAgentRun = run.run_kind === 'agent'; + + return { + run_id: run.id, + workflow_id: run.workflow_id, + status: run.status, + started_at: run.started_at ? new Date(run.started_at).toISOString() : null, + closed_at: run.completed_at ? new Date(run.completed_at).toISOString() : null, + topic: isAgentRun ? run.topic : run.title, + input: isAgentRun && run.data ? { data: run.data } : undefined, + interaction_name: isAgentRun ? run.interaction_name : undefined, + visibility: run.visibility, + activity_state: run.activity_state, + interactive: isAgentRun ? run.interactive : undefined, + }; +} + function getConversationLabel(conv: WorkflowRun, t: (key: string) => string): string { if (conv.topic) return conv.topic; // input is not populated by listConversations, but check anyway for forward compat @@ -68,13 +86,7 @@ export function PluginSidebar() { limit: 20, sort: 'started_at', order: 'desc', - }).then(response => setConversations(response.items.map(r => ({ - run_id: r.id, - workflow_id: r.workflow_id, - started_at: r.started_at ? new Date(r.started_at).toISOString() : null, - topic: r.topic, - input: r.data ? { data: r.data } : undefined, - } as WorkflowRun)))); + }).then(response => setConversations(response.items.map(toWorkflowRun))); }, [client]); const grouped = useMemo(() => groupByDate(conversations, t), [conversations, t]); From a1d420bcbecddc9f98414aa6d471dc939abbfdf4 Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Tue, 28 Apr 2026 13:48:22 +0900 Subject: [PATCH 31/75] refactor: remove deprecated worker commands and related files --- .../cli/bin/docker-credential-vertesia.js | 3 - packages/cli/package.json | 3 +- packages/cli/src/index.ts | 2 - packages/cli/src/worker/commands.ts | 179 ------------------ packages/cli/src/worker/connect.ts | 72 ------- packages/cli/src/worker/docker-credential.ts | 50 ----- packages/cli/src/worker/docker.ts | 93 --------- packages/cli/src/worker/index.ts | 110 ----------- packages/cli/src/worker/project.ts | 155 --------------- packages/cli/src/worker/refresh.ts | 50 ----- packages/cli/src/worker/registry.ts | 58 ------ packages/cli/src/worker/version.ts | 3 - 12 files changed, 1 insertion(+), 777 deletions(-) delete mode 100755 packages/cli/bin/docker-credential-vertesia.js delete mode 100644 packages/cli/src/worker/commands.ts delete mode 100644 packages/cli/src/worker/connect.ts delete mode 100644 packages/cli/src/worker/docker-credential.ts delete mode 100644 packages/cli/src/worker/docker.ts delete mode 100644 packages/cli/src/worker/index.ts delete mode 100644 packages/cli/src/worker/project.ts delete mode 100644 packages/cli/src/worker/refresh.ts delete mode 100644 packages/cli/src/worker/registry.ts delete mode 100644 packages/cli/src/worker/version.ts diff --git a/packages/cli/bin/docker-credential-vertesia.js b/packages/cli/bin/docker-credential-vertesia.js deleted file mode 100755 index 9713f4f56..000000000 --- a/packages/cli/bin/docker-credential-vertesia.js +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env node - -import "../lib/worker/docker-credential.js"; diff --git a/packages/cli/package.json b/packages/cli/package.json index 263b4a0f9..86db0d80c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -4,8 +4,7 @@ "description": "The Vertesia command-line interface (CLI) provides a set of commands to manage and interact with the Vertesia Platform.", "type": "module", "bin": { - "vertesia": "./bin/app.js", - "docker-credential-vertesia": "./bin/docker-credential-vertesia.js" + "vertesia": "./bin/app.js" }, "main": "./lib/index.js", "types": "./lib/index.d.ts", diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index ee07e60ff..d560d2f48 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -12,7 +12,6 @@ import { AVAILABLE_REGIONS, DEFAULT_REGION, getConfigFile } from './profiles/ind import { listProjects, useProject } from './projects/index.js'; import runInteraction from './run/index.js'; import { runHistory } from './runs/index.js'; -import { registerWorkerCommand } from './worker/index.js'; import { registerWorkflowsCommand } from './workflows/index.js'; //warnIfNotLatest(); @@ -119,7 +118,6 @@ program.command("runs [interactionId]") runHistory(program, interactionId, options); }); -registerWorkerCommand(program); registerAppsCommand(program); registerAgentsCommand(program); registerArtifactsCommand(program); diff --git a/packages/cli/src/worker/commands.ts b/packages/cli/src/worker/commands.ts deleted file mode 100644 index ca4389481..000000000 --- a/packages/cli/src/worker/commands.ts +++ /dev/null @@ -1,179 +0,0 @@ -import colors from "ansi-colors"; -import fs from "fs"; -import os from "os"; -import { join } from "path"; -import { getClient } from "../client.js"; -import { config, getCloudTypeFromConfigUrl, Profile } from "../profiles/index.js"; -import { runDocker, runDockerWithOutput, runDockerWithWorkerConfig } from "./docker.js"; -import { WorkerProject } from "./project.js"; -import { tryRefreshProjectToken } from "./refresh.js"; -import { updateNpmrc } from "./registry.js"; -import { validateVersion } from "./version.js"; - -export enum PublishMode { - Push = 1, - Deploy = 2, - PushAndDeploy = 3, -} - -function shouldDeploy(mode: PublishMode) { - return mode & PublishMode.Deploy; -} - -function shouldPush(mode: PublishMode) { - return mode & PublishMode.Push; -} - -const TARGET_PLATFORM = "linux/amd64"; -const LATEST_VERSION = "latest"; - -async function pushImage(project: WorkerProject, version: string) { - await tryRefreshProjectToken(project); - - const localTag = project.getLocalDockerTag(version); - const remoteTag = project.getVertesiaDockerTag(version); - - console.log(`Pushing docker image ${remoteTag}`); - runDocker(["tag", localTag, remoteTag]); - runDockerWithWorkerConfig(["push", remoteTag]); -} - -async function triggerDeploy(profile: Profile, project: WorkerProject, version: string) { - const environment = getCloudTypeFromConfigUrl(profile.config_url); - const client = await getClient(); - console.log(`Deploy worker ${project.getWorkerId()}:${version} to ${environment}`); - await client.store.workers.deploy({ - environment, - workerId: project.getWorkerId(), - version, - }); -} - -export async function publish(version: string, mode: PublishMode) { - if (!validateVersion(version)) { - console.log(`Invalid version format: ${version}. Use major.minor.patch[-modifier] format.`); - process.exit(1); - } - if (!config.current) { - console.log("No active profile is defined."); - process.exit(1); - } - const profile = config.current; - const project = new WorkerProject(); - if (shouldPush(mode)) { - await pushImage(project, version); - } - if (shouldDeploy(mode)) { - await triggerDeploy(profile, project, version); - } -} - -export async function build(contextDir = ".") { - const project = new WorkerProject(); - - const refreshResult = await tryRefreshProjectToken(project); - if (refreshResult) { - await updateNpmrc(project, refreshResult.profile); - } - - const tag = project.getLocalDockerTag(LATEST_VERSION); - const args = ["buildx", "build", "--platform", TARGET_PLATFORM, "-t", tag]; - if (contextDir !== ".") { - args.push("-f", "Dockerfile"); - } - console.log(`Building docker image: ${tag}`); - runDocker([...args, contextDir]); -} - -export async function release(version: string) { - if (!validateVersion(version)) { - console.log(`Invalid version format: ${version}. Use major.minor.patch[-modifier] format.`); - process.exit(1); - } - const project = new WorkerProject(); - const latestTag = project.getLocalDockerTag(LATEST_VERSION); - const versionTag = project.getLocalDockerTag(version); - - runDocker(["tag", latestTag, versionTag]); -} - -export function run(version: string = LATEST_VERSION) { - if (version !== LATEST_VERSION && !validateVersion(version)) { - console.log(`Invalid version format: ${version}. Use major.minor.patch[-modifier] format.`); - process.exit(1); - } - const project = new WorkerProject(); - const tag = project.getLocalDockerTag(version); - const args = ["run", "--platform", TARGET_PLATFORM, "--env-file", ".env"]; - const googleCredsFile = getGoogleCredentialsFile(); - if (googleCredsFile) { - args.push("-v", `${googleCredsFile}:/tmp/google-credentials.json`); - args.push("-e", "GOOGLE_APPLICATION_CREDENTIALS=/tmp/google-credentials.json"); - } - args.push(tag); - runDocker(args); -} - -export async function listVersions() { - const project = new WorkerProject(); - if (!project.packageJson.vertesia?.image) { - console.error("Invalid package.json. Missing vertesia.image configuration."); - process.exit(1); - } - const out = runDockerWithOutput(["images", "--format", "{{.Repository}}:{{.Tag}}"]); - const lines = out.trim().split("\n"); - const localTagPrefix = project.getLocalDockerTag(""); - const remoteTagPrefix = project.getVertesiaDockerTag(""); - const localTags: Record = {}; - const remoteTags: Record = {}; - const versions = new Set(); - for (const line of lines) { - const index = line.indexOf(":"); - if (index > 0) { - const name = line.substring(0, index); - const version = line.substring(index + 1); - if (line.startsWith(localTagPrefix)) { - localTags[version] = { version, name }; - versions.add(version); - } else if (line.startsWith(remoteTagPrefix)) { - remoteTags[version] = { version, name }; - versions.add(version); - } - } - } - - versions.delete(LATEST_VERSION); - printVersion(LATEST_VERSION, localTags[LATEST_VERSION], remoteTags[LATEST_VERSION]); - Array.from(versions).sort((left, right) => right.localeCompare(left)).forEach((version) => { - printVersion(version, localTags[version], remoteTags[version]); - }); -} - -function printVersion(version: string, local: TagInfo | undefined, remote: TagInfo | undefined) { - if (!local && !remote) { - return; - } - const isLatest = version === LATEST_VERSION; - console.log(colors.bold(isLatest ? version : `v${version}`)); - console.log(`\t${colors.green("Local tag:")} ${local ? `${local.name}:${version}` : "N/A"}`); - if (remote) { - console.log(`\t${colors.green("published to:")} ${remote.name}:${version}`); - } else if (isLatest) { - console.log(colors.dim("\tcannot be published")); - } else { - console.log(colors.dim("\tnot published")); - } -} - -interface TagInfo { - version: string; - name: string; -} - -function getGoogleCredentialsFile() { - const file = join(os.homedir(), ".config/gcloud/application_default_credentials.json"); - if (fs.existsSync(file)) { - return file; - } - return null; -} diff --git a/packages/cli/src/worker/connect.ts b/packages/cli/src/worker/connect.ts deleted file mode 100644 index 4d16a0699..000000000 --- a/packages/cli/src/worker/connect.ts +++ /dev/null @@ -1,72 +0,0 @@ -import enquirer from "enquirer"; -import { createProfile, refreshProfile } from "../profiles/commands.js"; -import { config, shouldRefreshProfileToken } from "../profiles/index.js"; -import { ConfigResult } from "../profiles/server/index.js"; -import { WorkerProject } from "./project.js"; -import { updateNpmrc } from "./registry.js"; - -const { prompt } = enquirer; - -interface ConnectOptions { - nonInteractive?: boolean; - profile?: string; -} - -export async function connectToProject(options: ConnectOptions) { - const allowInteraction = !options.nonInteractive; - const project = new WorkerProject(); - const pkg = project.packageJson; - let profileName: string | undefined = options.profile || pkg.vertesia.profile; - const onAuthenticationDone = async (result: ConfigResult | undefined) => { - if (result) { - await updateNpmrc(project, result.profile); - } - }; - try { - if (allowInteraction && !profileName) { - profileName = await askProfileName(); - if (!profileName) { - profileName = await createProfile(undefined, { onResult: onAuthenticationDone }); - } - } - if (!profileName) { - console.log("Profile not specified. When using --non-interactive mode you may want to specify a profile"); - process.exit(1); - } - const profile = config.getProfile(profileName); - if (!profile) { - console.log("Profile not found:", profileName); - process.exit(1); - } - if (allowInteraction && shouldRefreshProfileToken(profile, 10)) { - console.log("Refreshing auth token for profile:", profileName); - await refreshProfile(profileName, onAuthenticationDone); - } else { - await updateNpmrc(project, profileName); - } - } finally { - if (pkg.vertesia.profile !== profileName) { - pkg.vertesia.profile = profileName; - pkg.save(); - } - } -} - -async function askProfileName() { - const profiles = config.profiles.map((profile) => profile.name); - if (profiles.length > 0) { - const newProfile = "Create new profile"; - profiles.push(newProfile); - const answer: Record = await prompt({ - name: "profile", - type: "select", - message: "Select a profile to use when connecting.", - initial: profiles.length - 1, - choices: profiles, - }); - if (newProfile !== answer.profile) { - return answer.profile; - } - } - return undefined; -} diff --git a/packages/cli/src/worker/docker-credential.ts b/packages/cli/src/worker/docker-credential.ts deleted file mode 100644 index dd44b4aba..000000000 --- a/packages/cli/src/worker/docker-credential.ts +++ /dev/null @@ -1,50 +0,0 @@ -import fs from "node:fs"; -import { getDockerCredentials } from "./docker.js"; - -function handleNoOp() { - process.exit(0); -} - -async function handleGet(serverUrl: string) { - if (!serverUrl.endsWith("-docker.pkg.dev")) { - process.exit(0); - } - try { - const credentials = await getDockerCredentials(serverUrl); - if (process.env.DEBUG_DOCKER_CREDS) { - fs.writeFileSync("./docker-creds-helper.log", `Get token for registry ${serverUrl} => ${JSON.stringify(credentials, null, 2)}`, "utf8"); - } - process.stdout.write(JSON.stringify(credentials)); - } catch (error: any) { - fs.writeFileSync("./docker-creds-helper-error.log", `Get token for registry ${serverUrl} => ${JSON.stringify(error, null, 2)}`, "utf8"); - console.error("Error fetching credentials:", error.message); - process.exit(1); - } -} - -function main() { - const command = process.argv[2]; - - if (!command) { - console.error("No command provided"); - process.exit(1); - } - - switch (command) { - case "get": { - const stdin = fs.readFileSync(0, "utf-8"); - void handleGet(stdin.trim()); - break; - } - case "store": - case "erase": - case "list": - handleNoOp(); - break; - default: - console.error(`Unknown command: ${command}`); - process.exit(1); - } -} - -main(); diff --git a/packages/cli/src/worker/docker.ts b/packages/cli/src/worker/docker.ts deleted file mode 100644 index 4aa26f3db..000000000 --- a/packages/cli/src/worker/docker.ts +++ /dev/null @@ -1,93 +0,0 @@ -import ansiColors from "ansi-colors"; -import { spawnSync } from "node:child_process"; -import { existsSync } from "node:fs"; -import { join } from "node:path"; -import { getClient } from "../client.js"; -import { config } from "../profiles/index.js"; -import { WorkerProject } from "./project.js"; - -const LOCAL_DOCKER_CONFIG_DIR = ".docker"; - -export function generateDockerConfig() { - return JSON.stringify({ - credHelpers: { - "us-central1-docker.pkg.dev": "vertesia", - }, - }, undefined, 2); -} - -async function getGoogleToken(pkgDir?: string) { - const project = new WorkerProject(pkgDir); - const pkg = project.packageJson; - if (!pkg.vertesia.profile) { - throw new Error("Profile entry not found in package.json"); - } - config.use(pkg.vertesia.profile); - const client = await getClient(); - const result = await client.account.getGoogleToken(); - return result.token; -} - -export async function getDockerCredentials(serverUrl: string) { - const token = await getGoogleToken(); - return { - ServerURL: serverUrl, - Username: "oauth2accesstoken", - Secret: token, - }; -} - -export function runDockerWithWorkerConfig(args: string[]) { - const configDir = join(process.cwd(), LOCAL_DOCKER_CONFIG_DIR); - const baseArgs: string[] = []; - if (existsSync(configDir)) { - baseArgs.push("--config", configDir); - } - return runDocker(baseArgs.concat(args)); -} - -export function runDocker(args: string[]) { - const verbose = process.argv.includes("--verbose"); - if (verbose) { - console.log(`Running: ${ansiColors.magenta(`docker ${args.join(" ")}`)}`); - } - const result = spawnSync("docker", args, { - stdio: "inherit", - env: { - ...process.env, - DOCKER_BUILDKIT: "1", - }, - }); - if (result.error) { - console.error(`Failed to execute command "docker ${args.join(" ")}":`, result.error); - process.exit(2); - } - if (result.status !== 0) { - console.error(`Command "docker ${args.join(" ")}" failed with exit code ${result.status}`); - process.exit(result.status ?? 1); - } - return result; -} - -export function runDockerWithOutput(args: string[]) { - const verbose = process.argv.includes("--verbose"); - if (verbose) { - console.log(`Running: ${ansiColors.magenta(`docker ${args.join(" ")}`)}`); - } - const result = spawnSync("docker", args, { - encoding: "utf-8", - env: { - ...process.env, - DOCKER_BUILDKIT: "1", - }, - }); - if (result.error) { - console.error(`Failed to execute command "docker ${args.join(" ")}":`, result.error); - process.exit(2); - } - if (result.status !== 0) { - console.error(`Command "docker ${args.join(" ")}" failed with exit code ${result.status}\n${result.stderr}`); - process.exit(result.status ?? 1); - } - return result.stdout.trim(); -} diff --git a/packages/cli/src/worker/index.ts b/packages/cli/src/worker/index.ts deleted file mode 100644 index 632151207..000000000 --- a/packages/cli/src/worker/index.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { Command } from "commander"; -import { build, listVersions, publish, PublishMode, release, run } from "./commands.js"; -import { connectToProject } from "./connect.js"; -import { getGooglePrincipal, getGoogleToken } from "./registry.js"; - -const DEPRECATION_MESSAGE = "Warning: `vertesia worker` is deprecated and will be removed in a future release."; - -function warnDeprecatedWorkerCommand() { - console.warn(DEPRECATION_MESSAGE); -} - -export function registerWorkerCommand(program: Command) { - const worker = program.command("worker") - .description("Build, release, and deploy custom workflow workers (deprecated)"); - - worker.command("connect [pkgDir]") - .description("Connect a node package to a Vertesia project. If no packageDir is specified the current dir will be used.") - .option("-I, --non-interactive", "Don't do interactions with the user. Assume the user is already authenticated.") - .option("-p, --profile [profile]", "The profile name to use. If not specified the one from the package.json will be used.") - .action(async (pkgDir: string, options: Record = {}) => { - warnDeprecatedWorkerCommand(); - if (pkgDir) { - process.chdir(pkgDir); - } - await connectToProject(options); - }); - - worker.command("publish ") - .description("Deploy a custom workflow worker. The user will be asked for a target image version.") - .option("-d, --dir [project_dir]", "Use this as the current directory.") - .option("--push-only", "If used the docker image will be push only. The deployment will not be triggered.") - .option("--deploy-only", "If used the docker is assumed to be already pushed and only the deploy will be triggered.") - .option("--verbose", "Print more information.") - .action(async (version: string, options: Record = {}) => { - warnDeprecatedWorkerCommand(); - if (options.dir) { - process.chdir(options.dir); - } - let mode: PublishMode; - if (options.pushOnly) { - mode = PublishMode.Push; - } else if (options.deployOnly) { - mode = PublishMode.Deploy; - } else { - mode = PublishMode.PushAndDeploy; - } - await publish(version, mode); - }); - - worker.command("build") - .description("Build a local docker image using 'latest' as version.") - .option("-d, --dir [project_dir]", "Use this as the current directory.") - .option("-c, --context [context]", "The docker build context to use. Defaults to the project directory.") - .option("--verbose", "Print more information.") - .action(async (options: Record) => { - warnDeprecatedWorkerCommand(); - if (options.dir) { - process.chdir(options.dir); - } - await build(options.context); - }); - - worker.command("release [version]") - .description("Promote the latest version to a named version (tag it).") - .option("-d, --dir [project_dir]", "Use this as the current directory.") - .option("--verbose", "Print more information.") - .action(async (version: string, options: Record) => { - warnDeprecatedWorkerCommand(); - if (options.dir) { - process.chdir(options.dir); - } - await release(version); - }); - - worker.command("run [version]") - .description("Run the docker image identified by the given version or the 'latest' version if no version is given.") - .option("-d, --dir [project_dir]", "Use this as the current directory.") - .option("--verbose", "Print more information.") - .action(async (version: string, options: Record) => { - warnDeprecatedWorkerCommand(); - if (options.dir) { - process.chdir(options.dir); - } - await run(version); - }); - - worker.command("versions") - .description("List existing versions.") - .option("--verbose", "Print more information.") - .action(async () => { - warnDeprecatedWorkerCommand(); - await listVersions(); - }); - - worker.command("gtoken") - .description("Get a google cloud token for the current vertesia project.") - .option("-p, --profile", "The profile name to use. If specified it will be used instead of the current profile.") - .action(async (options: Record = {}) => { - warnDeprecatedWorkerCommand(); - await getGoogleToken(program, options.profile); - }); - - worker.command("gprincipal") - .description("Get the google cloud principal for the current project.") - .option("-p, --profile", "The profile name to use. If specified it will be used instead of the current profile.") - .action(async (options: Record = {}) => { - warnDeprecatedWorkerCommand(); - await getGooglePrincipal(program, options.profile); - }); -} diff --git a/packages/cli/src/worker/project.ts b/packages/cli/src/worker/project.ts deleted file mode 100644 index 9c7ccdb01..000000000 --- a/packages/cli/src/worker/project.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { existsSync, readFileSync, writeFileSync } from "node:fs"; -import { join, resolve } from "node:path"; - -export interface WorkerConfig { - profile?: string; - pm?: string; - image?: { - repository: string; - organization: string; - name: string; - version?: string; - }; -} - -export interface WorkerPackageJson { - name: string; - version: string; - vertesia?: WorkerConfig; -} - -export class PackageJson implements WorkerPackageJson { - constructor(public file: string, public data: Record) { - if (!data.vertesia) { - data.vertesia = {}; - } - } - - get name() { - return this.data.name; - } - - set name(value: string) { - this.data.name = value; - } - - get version() { - return this.data.version; - } - - set version(value: string) { - this.data.version = value; - } - - get pm() { - return this.data.vertesia.pm; - } - - get profile() { - return this.data.vertesia.profile; - } - - set profile(value: string) { - this.data.vertesia.profile = value; - } - - getWorkerId() { - const image = this.vertesia.image; - if (!image || !image.organization || !image.name) { - console.log("Worker configuration not found or not valid in package.json"); - process.exit(1); - } - return `${image.organization}/${image.name}`; - } - - getLocalDockerTag(version: string) { - return `${this.getWorkerId()}:${version}`; - } - - getVertesiaDockerTag(version: string) { - const workerId = this.getWorkerId(); - let repo = this.vertesia.image.repository; - if (repo.endsWith("/")) { - repo = repo.slice(0, -1); - } - return `${repo}/workers/${workerId}:${version}`; - } - - get latestPublishedVersion() { - return this.vertesia.image.version; - } - - set latestPublishedVersion(version: string) { - this.vertesia.image.version = version; - } - - get vertesia() { - return this.data.vertesia; - } - - set vertesia(value: any) { - this.data.vertesia = value; - } - - save() { - writeFileSync(this.file, JSON.stringify(this.data, undefined, 2), "utf8"); - } -} - -export class WorkerProject { - dir: string; - packageJsonFile: string; - _pkg?: PackageJson; - - constructor(pkgDir?: string) { - const resolvedDir = resolve(pkgDir || process.cwd()); - if (!existsSync(resolvedDir)) { - console.log("Directory not found:", resolvedDir); - process.exit(1); - } - const pkgFile = join(resolvedDir, "package.json"); - if (!existsSync(pkgFile)) { - console.log("package.json not found at", pkgFile); - process.exit(1); - } - this.dir = resolvedDir; - this.packageJsonFile = pkgFile; - } - - get npmrcFile() { - return join(this.dir, ".npmrc"); - } - - get dockerConfigFile() { - return join(this.dir, "docker.json"); - } - - get packageJson() { - if (!this._pkg) { - const pkgContent = readFileSync(this.packageJsonFile, "utf8"); - this._pkg = new PackageJson(this.packageJsonFile, JSON.parse(pkgContent)); - } - return this._pkg; - } - - getWorkerId() { - return this.packageJson.getWorkerId(); - } - - getVertesiaDockerTag(version: string) { - return this.packageJson.getVertesiaDockerTag(version); - } - - getLocalDockerTag(version: string) { - return this.packageJson.getLocalDockerTag(version); - } - - buildSources() { - if (!this.packageJson.pm) { - console.error("No package manager configuration found in package.json: vertesia.pm"); - process.exit(1); - } - spawnSync(this.packageJson.pm, ["run", "build"], { stdio: "inherit" }); - } -} diff --git a/packages/cli/src/worker/refresh.ts b/packages/cli/src/worker/refresh.ts deleted file mode 100644 index 3796defe1..000000000 --- a/packages/cli/src/worker/refresh.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { refreshProfile } from "../profiles/commands.js"; -import { config, Profile, shouldRefreshProfileToken } from "../profiles/index.js"; -import { ConfigResult } from "../profiles/server/index.js"; -import { WorkerProject } from "./project.js"; - -export function tryRefreshProjectToken(project: WorkerProject): Promise { - const profileName = project.packageJson.vertesia?.profile; - if (!profileName) { - console.error("No vertesia.profile entry found in package.json"); - process.exit(1); - } - const profile = config.getProfile(profileName); - if (!profile) { - console.error(`No such profile exists: ${profileName}`); - process.exit(1); - } - return tryRefreshToken(profile); -} - -export function tryRefreshToken(profile: Profile): Promise { - const abortController = new AbortController(); - - const handleSignal = () => { - abortController.abort(); - console.log("\nToken refresh interrupted"); - process.exit(0); - }; - - process.on("SIGINT", handleSignal); - process.on("SIGTERM", handleSignal); - - return new Promise((resolve) => { - if (!shouldRefreshProfileToken(profile, 10)) { - process.off("SIGINT", handleSignal); - process.off("SIGTERM", handleSignal); - resolve(undefined); - return; - } - - console.log("Refreshing auth token for profile:", profile.name); - - const wrappedResolve = (result: ConfigResult | undefined) => { - process.off("SIGINT", handleSignal); - process.off("SIGTERM", handleSignal); - resolve(result); - }; - - refreshProfile(profile.name, wrappedResolve, abortController.signal); - }); -} diff --git a/packages/cli/src/worker/registry.ts b/packages/cli/src/worker/registry.ts deleted file mode 100644 index 86d41960d..000000000 --- a/packages/cli/src/worker/registry.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Command } from "commander"; -import { readFileSync, writeFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { getClient } from "../client.js"; -import { config } from "../profiles/index.js"; -import { WorkerProject } from "./project.js"; - -const REGISTRY_URI_ABS_PATH = "//us-central1-npm.pkg.dev/dengenlabs/npm/"; - -function getRegistryLine() { - return `@dglabs:registry=https:${REGISTRY_URI_ABS_PATH}`; -} - -function getRegistryAuthTokenLine(token: string) { - return `${REGISTRY_URI_ABS_PATH}:_authToken=${token}`; -} - -export async function getGoogleToken(program: Command, profileName?: string) { - if (profileName) { - config.use(profileName); - } - const client = await getClient(program); - console.log((await client.account.getGoogleToken()).token); -} - -export async function getGooglePrincipal(program: Command, profileName?: string) { - if (profileName) { - config.use(profileName); - } - const client = await getClient(program); - console.log((await client.account.getGoogleToken()).principal); -} - -export async function updateNpmrc(project: WorkerProject, profile: string) { - config.use(profile); - await createOrUpdateNpmRegistry(project.npmrcFile); -} - -export async function createOrUpdateNpmRegistry(npmrcFile: string) { - const client = await getClient(); - const gtok = await client.account.getGoogleToken(); - - const resolvedFile = resolve(npmrcFile); - const content = (() => { - try { - return readFileSync(resolvedFile, "utf-8"); - } catch { - return ""; - } - })(); - const lines = content.trim().split("\n").filter((line) => !line.includes(REGISTRY_URI_ABS_PATH)); - - lines.push(getRegistryLine()); - lines.push(getRegistryAuthTokenLine(gtok.token)); - const out = `${lines.join("\n")}\n`; - - writeFileSync(resolvedFile, out, "utf8"); -} diff --git a/packages/cli/src/worker/version.ts b/packages/cli/src/worker/version.ts deleted file mode 100644 index d73fe1358..000000000 --- a/packages/cli/src/worker/version.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function validateVersion(version: string) { - return /^([0-9]+)\.([0-9]+)\.([0-9]+)(-[a-zA-Z_0-9-]+)?$/.test(version); -} From e5875b59a5a38df1fa855e5cb9cc6d14e6e814e1 Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Tue, 28 Apr 2026 15:15:55 +0900 Subject: [PATCH 32/75] refactor: simplify delay function and improve abort handling --- packages/cli/src/profiles/oauth.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/profiles/oauth.ts b/packages/cli/src/profiles/oauth.ts index 4c9f31492..3b26f71e4 100644 --- a/packages/cli/src/profiles/oauth.ts +++ b/packages/cli/src/profiles/oauth.ts @@ -385,19 +385,15 @@ function throwIfAborted(signal?: AbortSignal): void { async function delay(milliseconds: number, signal?: AbortSignal): Promise { throwIfAborted(signal); await new Promise((resolve, reject) => { - let timeout: ReturnType; - const onAbort = () => { - clearTimeout(timeout); - signal?.removeEventListener('abort', onAbort); - reject(new Error('Authentication aborted.')); - }; - const cleanup = () => { + const timeout = setTimeout(() => { signal?.removeEventListener('abort', onAbort); - }; - timeout = setTimeout(() => { - cleanup(); resolve(); }, milliseconds); + function onAbort() { + clearTimeout(timeout); + signal?.removeEventListener('abort', onAbort); + reject(new Error('Authentication aborted.')); + } signal?.addEventListener('abort', onAbort, { once: true }); }); } From 8ead9322d80e783fe752cd0f4dc80fd56ce7a350 Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Tue, 28 Apr 2026 16:02:24 +0900 Subject: [PATCH 33/75] feat: enhance package publishing scripts with workspace filtering --- .github/bin/publish-all-packages.sh | 76 ++++++++++++++---------- .github/bin/test-template-integration.sh | 34 +++++++---- 2 files changed, 69 insertions(+), 41 deletions(-) diff --git a/.github/bin/publish-all-packages.sh b/.github/bin/publish-all-packages.sh index 45a6f2cd9..4ac97d3ee 100755 --- a/.github/bin/publish-all-packages.sh +++ b/.github/bin/publish-all-packages.sh @@ -12,6 +12,20 @@ set -e # Functions # ============================================================================= +workspace_package_dirs() { + local repo_root + repo_root="$(git rev-parse --show-toplevel)" + + # Use pnpm workspace filtering so pnpm-workspace.yaml exclusions are authoritative. + pnpm -r --filter "./packages/**" exec pwd | while IFS= read -r pkg_dir; do + case "$pkg_dir" in + "${repo_root}"/packages/*) + [ -f "${pkg_dir}/package.json" ] && printf '%s\n' "$pkg_dir" + ;; + esac + done +} + update_package_versions() { echo "=== Updating composableai package versions ===" @@ -61,31 +75,43 @@ update_package_versions() { publish_packages() { echo "=== Publishing composableai packages ===" - for pkg_dir in packages/*; do - if [ -d "$pkg_dir" ] && [ -f "$pkg_dir/package.json" ]; then - pkg_name=$(basename "$pkg_dir") - cd "$pkg_dir" + while IFS= read -r pkg_dir; do + pkg_name=$(basename "$pkg_dir") + cd "$pkg_dir" - pkg_version=$(pnpm pkg get version | tr -d '"') + pkg_version=$(pnpm pkg get version | tr -d '"') - # Fail if npm_tag is not set (safety check to prevent publishing without explicit tag) - if [ -z "$npm_tag" ]; then - echo "Error: npm_tag is not set. This indicates an invalid ref/version-type combination." - exit 1 - fi + # Fail if npm_tag is not set (safety check to prevent publishing without explicit tag) + if [ -z "$npm_tag" ]; then + echo "Error: npm_tag is not set. This indicates an invalid ref/version-type combination." + exit 1 + fi - echo "Publishing @vertesia/${pkg_name}@${pkg_version} with tag ${npm_tag}" + echo "Publishing @vertesia/${pkg_name}@${pkg_version} with tag ${npm_tag}" - # Publish - if [ -n "$DRY_RUN_FLAG" ]; then - pnpm publish --access public --tag "${npm_tag}" --no-git-checks ${DRY_RUN_FLAG} - else - pnpm publish --access public --tag "${npm_tag}" --no-git-checks - fi + # Publish + if [ -n "$DRY_RUN_FLAG" ]; then + pnpm publish --access public --tag "${npm_tag}" --no-git-checks ${DRY_RUN_FLAG} + else + pnpm publish --access public --tag "${npm_tag}" --no-git-checks + fi - cd ../.. + cd "$(git rev-parse --show-toplevel)" + done < <(workspace_package_dirs) +} + +write_package_summary_rows() { + local version="$1" + + while IFS= read -r pkg_dir; do + pkg_name=$(basename "$pkg_dir") + if [ "$DRY_RUN" = "true" ]; then + echo "| \`@vertesia/${pkg_name}\` | ${version} |" >> "$GITHUB_STEP_SUMMARY" + else + pkg_url="https://www.npmjs.com/package/@vertesia/${pkg_name}?activeTab=versions" + echo "| \`@vertesia/${pkg_name}\` | [${version}](${pkg_url}) |" >> "$GITHUB_STEP_SUMMARY" fi - done + done < <(workspace_package_dirs) } update_template_versions() { @@ -201,17 +227,7 @@ ${title} | ------- | ------- | EOF - for pkg_dir in packages/*; do - if [ -d "$pkg_dir" ] && [ -f "$pkg_dir/package.json" ]; then - pkg_name=$(basename "$pkg_dir") - if [ "$DRY_RUN" = "true" ]; then - echo "| \`@vertesia/${pkg_name}\` | ${version} |" >> "$GITHUB_STEP_SUMMARY" - else - pkg_url="https://www.npmjs.com/package/@vertesia/${pkg_name}?activeTab=versions" - echo "| \`@vertesia/${pkg_name}\` | [${version}](${pkg_url}) |" >> "$GITHUB_STEP_SUMMARY" - fi - fi - done + write_package_summary_rows "$version" # Add metadata cat >> "$GITHUB_STEP_SUMMARY" << EOF diff --git a/.github/bin/test-template-integration.sh b/.github/bin/test-template-integration.sh index 1a56bf6c0..c0772d94f 100755 --- a/.github/bin/test-template-integration.sh +++ b/.github/bin/test-template-integration.sh @@ -129,21 +129,33 @@ publish_to_verdaccio() { npm set "//${VERDACCIO_URL#http://}/:_authToken" "test-token" local count=0 - for pkg_dir in packages/*; do - if [ -d "$pkg_dir" ] && [ -f "$pkg_dir/package.json" ]; then - pkg_name=$(basename "$pkg_dir") - cd "$pkg_dir" - pkg_version=$(pnpm pkg get version | tr -d '"') - echo " Publishing @vertesia/${pkg_name}@${pkg_version}..." - pnpm publish --access public --tag "${NPM_TAG}" --no-git-checks --registry "${VERDACCIO_URL}" > /dev/null 2>&1 - count=$((count + 1)) - cd ../.. - fi - done + while IFS= read -r pkg_dir; do + pkg_name=$(basename "$pkg_dir") + cd "$pkg_dir" + pkg_version=$(pnpm pkg get version | tr -d '"') + echo " Publishing @vertesia/${pkg_name}@${pkg_version}..." + pnpm publish --access public --tag "${NPM_TAG}" --no-git-checks --registry "${VERDACCIO_URL}" > /dev/null 2>&1 + count=$((count + 1)) + cd "${SCRIPT_DIR}/../.." + done < <(workspace_package_dirs) echo "Published ${count} packages to verdaccio" } +workspace_package_dirs() { + local repo_root + repo_root="$(cd "${SCRIPT_DIR}/../.." && pwd)" + + # Use pnpm workspace filtering so pnpm-workspace.yaml exclusions are authoritative. + pnpm -r --filter "./packages/**" exec pwd | while IFS= read -r pkg_dir; do + case "$pkg_dir" in + "${repo_root}"/packages/*) + [ -f "${pkg_dir}/package.json" ] && printf '%s\n' "$pkg_dir" + ;; + esac + done +} + # ============================================================================= # Argument parsing # ============================================================================= From ac6c8cc51a90410fdbc69a49fe84b35b2e2e1202 Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Tue, 28 Apr 2026 16:46:04 +0900 Subject: [PATCH 34/75] chore: remove deprecated create-worker package and related files --- packages/create-worker/.gitignore | 3 - packages/create-worker/README.md | 243 ------------------- packages/create-worker/bin/create-worker.mjs | 2 - packages/create-worker/eslint.config.js | 32 --- packages/create-worker/package.json | 42 ---- packages/create-worker/src/main.ts | 63 ----- packages/create-worker/tsconfig.json | 44 ---- 7 files changed, 429 deletions(-) delete mode 100644 packages/create-worker/.gitignore delete mode 100644 packages/create-worker/README.md delete mode 100755 packages/create-worker/bin/create-worker.mjs delete mode 100644 packages/create-worker/eslint.config.js delete mode 100644 packages/create-worker/package.json delete mode 100644 packages/create-worker/src/main.ts delete mode 100644 packages/create-worker/tsconfig.json diff --git a/packages/create-worker/.gitignore b/packages/create-worker/.gitignore deleted file mode 100644 index b106fa9a0..000000000 --- a/packages/create-worker/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# generated files -lib/ - diff --git a/packages/create-worker/README.md b/packages/create-worker/README.md deleted file mode 100644 index 64baadb66..000000000 --- a/packages/create-worker/README.md +++ /dev/null @@ -1,243 +0,0 @@ -# @vertesia/create-worker - -This package scaffolds a Vertesia custom Temporal worker project. -Custom workers allow you to deploy your own Temporal workflows and activities to the Vertesia cloud platform. - -Visit https://vertesiahq.com for more information about Vertesia. - -## Requirements - -1. Docker (with buildx support) installed locally. -2. Vertesia CLI application. The CLI will be automatically installed when initializing the worker project if you didn't install it previously. - -## Initialize a Vertesia worker project - -Run the following command: - -```bash -npm init @vertesia/worker -``` - -Follow the instructions on screen. You need to define an organization and a name for your worker. The organization must be unique inside Vertesia and is usually the name of your Vertesia organization account. The worker name identifies the project within your organization. - -The generated project is a TypeScript project using [Temporal](https://temporal.io/) as the workflow system. - -## Project Structure - -The generated project includes: - -``` -├── src/ -│ ├── activities.ts # Activity implementations (API calls, I/O operations) -│ ├── workflows.ts # Workflow definitions (orchestration logic) -│ ├── main.ts # Worker entry point -│ ├── debug-replayer.ts # Debugging tool for workflow replay -│ ├── activities.test.ts # Unit tests for activities -│ └── test/ -│ └── utils.ts # Test utilities -├── bin/ -│ └── bundle-workflows.mjs # Workflow bundler script -├── vitest.config.ts # Test configuration -├── tsconfig.json # TypeScript configuration -├── tsconfig.test.json # TypeScript configuration for tests -├── Dockerfile # Container build configuration -└── package.json # Project configuration -``` - -## Development - -### Building - -```bash -pnpm install -pnpm run build -``` - -The build process: - -1. Compiles TypeScript to JavaScript -2. Bundles workflows into a single file (required by Temporal) - -### Testing - -The project uses Vitest with Temporal's `MockActivityEnvironment` for testing activities. - -```bash -pnpm test -``` - -Tests are located in `*.test.ts` files alongside the source code. - -### Developing workflows and activities - -**Activities** (`src/activities.ts`): - -- Activities are functions that perform I/O operations (API calls, file access, etc.) -- They run outside the Temporal workflow sandbox -- Use `getVertesiaClient(payload)` to get an authenticated Vertesia client -- Activities can be retried automatically on failure - -**Workflows** (`src/workflows.ts`): - -- Workflows orchestrate activities and define the business logic -- They must be deterministic (no direct I/O, random, or time operations) -- Use `proxyActivities` to call activities from workflows -- Workflows receive `WorkflowExecutionPayload` with `objectIds` and `vars` - -Export your workflows and activities from these files to make them available to the worker. - -## Run with a local Temporal server - -Running with a local Temporal server is useful for integration testing before deployment. - -### 1. Install and start Temporal - -Install the [Temporal CLI](https://docs.temporal.io/cli), then start the dev server: - -```bash -temporal server start-dev -``` - -### 2. Start the worker - -In another terminal: - -```bash -pnpm run start -``` - -### 3. Execute a workflow - -Using the Temporal CLI: - -```bash -temporal workflow start --name exampleWorkflow -t agents/your-org/your-worker --input-file INPUT.json -``` - -Where `INPUT.json` contains the workflow parameters: - -```json -{ - "objectIds": ["content-object-id"], - "event": "workflow_execution_request", - "auth_token": "your-auth-token", - "account_id": "your-account-id", - "project_id": "your-project-id", - "config": { - "store_url": "https://zeno-server.api.vertesia.io", - "studio_url": "https://studio-server.api.vertesia.io" - }, - "vars": { - "dryRun": true - } -} -``` - -## Debugging workflows - -You can debug workflows by replaying them locally using the Temporal replayer in `src/debug-replayer.ts`. - -See https://docs.temporal.io/develop/typescript/debugging for more information. - -## Configuration - -Worker configuration is defined in `package.json` under the `vertesia` section: - -```json -{ - "vertesia": { - "pm": "pnpm", - "image": { - "repository": "us-docker.pkg.dev/dengenlabs/us.gcr.io", - "organization": "your-org", - "name": "your-worker" - } - } -} -``` - -The worker domain (task queue) is automatically constructed as: `agents/{organization}/{name}` - -## Deployment - -### Build the Docker image - -```bash -vertesia worker build -``` - -This builds a Docker image tagged as `your-organization/your-worker:latest`. -This image is only for local testing. - -### Create a release version - -```bash -vertesia worker release -``` - -The version must be in `major.minor.patch[-modifier]` format (e.g., `1.0.0`, `1.0.0-rc1`). - -This creates a new Docker tag `your-organization/your-worker:version` from the `latest` image. - -### Publish to Vertesia - -```bash -vertesia worker publish -``` - -This pushes the image to Vertesia and deploys the worker. - -Options: - -- `--push-only`: Only push the image without deploying -- `--deploy-only`: Deploy a previously uploaded version - -### View versions - -```bash -vertesia worker versions -``` - -## Running deployed workflows - -Once deployed, workflows can be triggered via: - -**SDK:** - -```javascript -const run = await client.workflows.execute("exampleWorkflow", { - task_queue: "agents/your-org/your-worker", - objectIds: ["content-object-id"], - vars: { - dryRun: false, - }, -}); -``` - -**CLI:** - -```bash -vertesia workflows execute exampleWorkflow -o --queue "agents/your-org/your-worker" -f vars.json -``` - -**API:** - -```bash -curl --location 'https://api.vertesia.io/api/v1/workflows/execute/exampleWorkflow' \ ---header 'Authorization: Bearer ' \ ---header 'Content-Type: application/json' \ ---data '{ - "vars": { "dryRun": false }, - "task_queue": "agents/your-org/your-worker", - "objectIds": ["content-object-id"] -}' -``` - -## Dependencies - -Built with: - -- **Temporal**: Workflow orchestration -- **Vertesia SDK**: Platform integration -- **TypeScript**: Type-safe development -- **Vitest**: Testing framework diff --git a/packages/create-worker/bin/create-worker.mjs b/packages/create-worker/bin/create-worker.mjs deleted file mode 100755 index 9ff02d1f5..000000000 --- a/packages/create-worker/bin/create-worker.mjs +++ /dev/null @@ -1,2 +0,0 @@ -#!/usr/bin/env node -import "../lib/main.js" diff --git a/packages/create-worker/eslint.config.js b/packages/create-worker/eslint.config.js deleted file mode 100644 index 8dab5fc24..000000000 --- a/packages/create-worker/eslint.config.js +++ /dev/null @@ -1,32 +0,0 @@ -import js from '@eslint/js'; -import tseslint from 'typescript-eslint'; - -export default [ - { ignores: ['dist', 'node_modules', 'lib'] }, - { - files: ['**/*.{ts,tsx,js,jsx}'], - languageOptions: { - ecmaVersion: 'latest', - sourceType: 'module', - parser: tseslint.parser, - parserOptions: { - tsconfigRootDir: import.meta.dirname, - }, - }, - }, - js.configs.recommended, - ...tseslint.configs.recommended, - { - rules: { - '@typescript-eslint/no-explicit-any': 'warn', - '@typescript-eslint/no-unused-vars': ['warn', { - argsIgnorePattern: '^_', - varsIgnorePattern: '^_', - }], - '@typescript-eslint/no-unused-expressions': ['error', { - allowShortCircuit: true, - allowTernary: true, - }], - }, - }, -]; diff --git a/packages/create-worker/package.json b/packages/create-worker/package.json deleted file mode 100644 index cfa312ded..000000000 --- a/packages/create-worker/package.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "name": "@vertesia/create-worker", - "version": "1.1.0-dev.20260427.060440Z", - "description": "Initialize workflow worker package (deprecated - use @vertesia/create-plugin instead)", - "type": "module", - "bin": { - "create-worker": "./bin/create-worker.mjs" - }, - "main": "./lib/main.js", - "types": "./lib/main.d.ts", - "files": [ - "lib", - "bin" - ], - "license": "Apache-2.0", - "homepage": "https://docs.vertesiahq.com", - "keywords": [ - "workflow", - "temporal", - "agent", - "llm", - "composable", - "deprecated" - ], - "scripts": { - "lint": "eslint './src/**/*.{jsx,js,tsx,ts}'", - "build": "tsc --build", - "clean": "rm -rf ./lib ./tsconfig.tsbuildinfo && tsc --build" - }, - "devDependencies": { - "@eslint/js": "^10.0.1", - "@types/node": "^25.6.0", - "eslint": "^10.0.2", - "typescript": "^6.0.2", - "typescript-eslint": "^8.58.1" - }, - "repository": { - "type": "git", - "url": "https://github.com/vertesia/composableai.git", - "directory": "packages/create-worker" - } -} diff --git a/packages/create-worker/src/main.ts b/packages/create-worker/src/main.ts deleted file mode 100644 index bcd0fbdcd..000000000 --- a/packages/create-worker/src/main.ts +++ /dev/null @@ -1,63 +0,0 @@ -#!/usr/bin/env node - -/** - * @vertesia/create-worker - * - * This package has been deprecated in favor of @vertesia/create-plugin. - * It now acts as a thin wrapper that redirects to create-plugin with the worker template pre-selected. - */ - -import { spawn } from 'child_process'; - -const DEPRECATION_MESSAGE = ` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ │ -│ ⚠️ @vertesia/create-worker is deprecated │ -│ │ -│ Please use @vertesia/create-plugin instead: │ -│ │ -│ pnpm create @vertesia/plugin my-worker │ -│ npm create @vertesia/plugin my-worker │ -│ │ -│ Then select "Vertesia Workflow Worker" from the template list. │ -│ │ -│ Continuing with legacy behavior for backwards compatibility... │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -`; - -async function main(argv: string[]) { - console.log(DEPRECATION_MESSAGE); - - const projectName = argv[2]; - - if (!projectName) { - console.error('Please specify a project name:'); - console.error(' npx @vertesia/create-worker my-worker'); - process.exit(1); - } - - // Forward to create-plugin - user will need to select the worker template - console.log('Launching @vertesia/create-plugin...\n'); - - const child = spawn('npx', ['@vertesia/create-plugin', projectName], { - stdio: 'inherit', - shell: true - }); - - child.on('close', (code: number | null) => { - process.exit(code || 0); - }); - - child.on('error', (err: Error) => { - console.error('Failed to launch create-plugin:', err.message); - console.error('\nPlease install and run create-plugin directly:'); - console.error(` npx @vertesia/create-plugin ${projectName}`); - process.exit(1); - }); -} - -main(process.argv).catch(err => { - console.error("Error: ", err); - process.exit(1); -}); diff --git a/packages/create-worker/tsconfig.json b/packages/create-worker/tsconfig.json deleted file mode 100644 index ae40230b2..000000000 --- a/packages/create-worker/tsconfig.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "compilerOptions": { - "composite": true, - "rootDir": "./src", - "sourceMap": true, - "outDir": "./lib", - "forceConsistentCasingInFileNames": true, - "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ - "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ - "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */ - "target": "ES2024", - "useDefineForClassFields": true, - "lib": [ - "ES2024", - "DOM" - ], - "module": "NodeNext", - "moduleResolution": "NodeNext", - "types": ["node"], - "declaration": true, - "declarationMap": true, - "skipLibCheck": true, - /* Bundler mode */ - //"allowImportingTsExtensions": true, - "resolveJsonModule": true, - "isolatedModules": true, - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true - }, - "include": [ - "src" - ], - "exclude": [ - "template", - "node_modules", - "**/*.test.ts", - "lib", - "test" - ], - "references": [] -} From 700bb8a3bcf254a22d2fceec5949c6ef2bd11d3a Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Tue, 28 Apr 2026 17:14:02 +0900 Subject: [PATCH 35/75] chore: remove create-worker package and its dependencies from lockfile --- pnpm-lock.yaml | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 801c0544c..fabbb4f93 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -417,24 +417,6 @@ importers: specifier: ^6.0.2 version: 6.0.3 - packages/create-worker: - devDependencies: - '@eslint/js': - specifier: ^10.0.1 - version: 10.0.1(eslint@10.0.2(jiti@2.6.1)) - '@types/node': - specifier: ^25.6.0 - version: 25.6.0 - eslint: - specifier: ^10.0.2 - version: 10.0.2(jiti@2.6.1) - typescript: - specifier: ^6.0.2 - version: 6.0.3 - typescript-eslint: - specifier: ^8.58.1 - version: 8.58.1(eslint@10.0.2(jiti@2.6.1))(typescript@6.0.3) - packages/fusion-ux: dependencies: '@vertesia/common': From 531d06ca345e31cf3c511a98e521f708e78f961b Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Fri, 1 May 2026 17:24:15 +0900 Subject: [PATCH 36/75] feat: add process management features including listing, publishing, and reverting processes --- llumiverse | 2 +- packages/cli/src/index.ts | 23 +- packages/cli/src/profiles/commands.ts | 247 ++++++++++++++++++- packages/client/src/ProjectsApi.ts | 12 +- packages/client/src/store/ProcessApi.ts | 26 +- packages/common/src/apps.ts | 14 +- packages/common/src/store/agent-run.ts | 2 + packages/common/src/store/process.ts | 59 +++++ packages/tools-sdk/src/server.ts | 18 +- packages/tools-sdk/src/server/app-package.ts | 15 +- packages/tools-sdk/src/server/processes.ts | 37 +++ packages/tools-sdk/src/server/types.ts | 7 +- 12 files changed, 440 insertions(+), 22 deletions(-) create mode 100644 packages/tools-sdk/src/server/processes.ts diff --git a/llumiverse b/llumiverse index fb4d35998..fdf0ca139 160000 --- a/llumiverse +++ b/llumiverse @@ -1 +1 @@ -Subproject commit fb4d35998ace3962b7b1a0309efe42229b1e6999 +Subproject commit fdf0ca1398e2a0ef24a291f34b9336790678ec91 diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index d560d2f48..10caf9b70 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -7,7 +7,22 @@ import { listEnvironments } from './envs/index.js'; import { listInteractions } from './interactions/index.js'; import { registerObjectsCommand } from './objects/index.js'; import { getVersion, upgrade } from './package.js'; -import { createProfile, deleteProfile, listProfiles, loginProfile, logoutProfile, showActiveAuthToken, showActiveIdToken, showProfile, tryRefreshToken, updateCurrentProfile, updateProfile, useProfile, type CreateProfileOptions } from './profiles/commands.js'; +import { + createProfile, + deleteProfile, + listProfiles, + loginProfile, + logoutProfile, + showActiveAuthToken, + showActiveIdToken, + showAuthDetails, + showProfile, + tryRefreshToken, + updateCurrentProfile, + updateProfile, + useProfile, + type CreateProfileOptions, +} from './profiles/commands.js'; import { AVAILABLE_REGIONS, DEFAULT_REGION, getConfigFile } from './profiles/index.js'; import { listProjects, useProject } from './projects/index.js'; import runInteraction from './run/index.js'; @@ -62,6 +77,12 @@ authRoot.command("id-token") .description("Show the ID token stored for the current selected profile.") .action(() => showActiveIdToken()) +authRoot.command("details") + .alias("info") + .description("Show non-secret authentication details for the active credential.") + .option("--json", "Print authentication details as JSON.") + .action((options: { json?: boolean }) => showAuthDetails(options)) + authRoot.command("refresh") .description("Refresh the auth token used by the current profile. An alias to 'vertesia profiles refresh'.") .option("-p, --project ", "Refresh the current profile token for the given project ID") diff --git a/packages/cli/src/profiles/commands.ts b/packages/cli/src/profiles/commands.ts index f2fe85a81..0caf79264 100644 --- a/packages/cli/src/profiles/commands.ts +++ b/packages/cli/src/profiles/commands.ts @@ -2,8 +2,22 @@ import { VertesiaClient } from '@vertesia/client'; import colors from 'ansi-colors'; import enquirer from "enquirer"; import jwt from 'jsonwebtoken'; -import { AVAILABLE_REGIONS, DEFAULT_REGION, Region, config, getConfigUrl, getServerUrls, shouldRefreshProfileToken } from "./index.js"; -import { deleteAuthBundle, getAccessTokenExpiry, readAuthBundle, writeAuthBundle } from "./keyring.js"; +import { + AVAILABLE_REGIONS, + DEFAULT_REGION, + Region, + config, + getConfigUrl, + getServerUrls, + shouldRefreshProfileToken, +} from "./index.js"; +import { + deleteAuthBundle, + getAccessTokenExpiry, + isKeyringAvailable, + readAuthBundle, + writeAuthBundle, +} from "./keyring.js"; import { ensureProfileAccessToken, refreshCurrentProfileAuthentication, refreshProfileAuthentication } from './auth.js'; import { ConfigResult } from './server/index.js'; const { prompt } = enquirer; @@ -20,6 +34,52 @@ interface CliPromptQuestion { validate?: (value: string) => boolean | string; } +interface AuthDetailsOptions { + json?: boolean; +} + +interface TokenDetails { + present: boolean; + type?: 'jwt' | 'opaque'; + expires_at?: string; + expired?: boolean; + issuer?: string; + subject?: string; + audience?: string; + account?: string; + project?: string; +} + +interface AuthDetailsPayload { + selected_profile?: string; + profile?: { + name: string; + account: string; + project: string; + config_url: string; + studio_server_url: string; + zeno_server_url: string; + region?: Region; + }; + keyring_available: boolean; + active_credential_source: string; + environment: { + credential?: string; + studio_server_url?: string; + zeno_server_url?: string; + token_server_url?: string; + project?: string; + }; + stored_credentials?: { + access_token: TokenDetails; + refresh_token: TokenDetails; + id_token: TokenDetails; + oauth_client_id?: string; + oauth_resource?: string; + }; + active_token: TokenDetails; +} + export async function listProfiles() { const selected = config.current?.name; @@ -68,6 +128,56 @@ export function showProfile(name?: string) { } } +export function showAuthDetails(options: AuthDetailsOptions = {}) { + const envAuth = readEnvCredential(); + const profile = config.current; + const bundle = profile ? readAuthBundle(profile.name) : undefined; + const profileAccessToken = bundle?.accessToken || profile?.apikey; + const activeToken = envAuth?.token || profileAccessToken; + const activeCredentialSource = envAuth + ? `environment:${envAuth.name}` + : profileAccessToken + ? `profile:${profile?.name}` + : 'none'; + + const payload: AuthDetailsPayload = { + selected_profile: profile?.name, + profile: profile && { + name: profile.name, + account: profile.account, + project: profile.project, + config_url: profile.config_url, + studio_server_url: profile.studio_server_url, + zeno_server_url: profile.zeno_server_url, + region: profile.region, + }, + keyring_available: isKeyringAvailable(), + active_credential_source: activeCredentialSource, + environment: { + credential: envAuth?.name, + studio_server_url: process.env.VERTESIA_SERVER_URL || process.env.COMPOSABLE_PROMPTS_SERVER_URL, + zeno_server_url: process.env.VERTESIA_STORE_URL || process.env.ZENO_SERVER_URL, + token_server_url: process.env.VERTESIA_TOKEN_SERVER_URL, + project: process.env.VERTESIA_PROJECT_ID || process.env.COMPOSABLE_PROMPTS_PROJECT_ID, + }, + stored_credentials: profile ? { + access_token: readTokenDetails(profileAccessToken, bundle?.accessTokenExpiresAt), + refresh_token: readTokenDetails(bundle?.refreshToken, bundle?.refreshTokenExpiresAt), + id_token: readTokenDetails(bundle?.idToken), + oauth_client_id: bundle?.oauthClientId, + oauth_resource: bundle?.oauthResource, + } : undefined, + active_token: readTokenDetails(activeToken, envAuth ? undefined : bundle?.accessTokenExpiresAt), + }; + + if (options.json) { + console.log(JSON.stringify(payload, undefined, 4)); + return; + } + + printAuthDetails(payload); +} + export async function showActiveAuthToken() { const envToken = process.env.VERTESIA_TOKEN || process.env.VERTESIA_APIKEY @@ -381,6 +491,82 @@ function readTokenRefs(token: string): CredentialRefs { }; } +function readEnvCredential(): { name: string; token: string } | undefined { + if (process.env.VERTESIA_TOKEN) { + return { + name: 'VERTESIA_TOKEN', + token: process.env.VERTESIA_TOKEN, + }; + } + if (process.env.VERTESIA_APIKEY) { + return { + name: 'VERTESIA_APIKEY', + token: process.env.VERTESIA_APIKEY, + }; + } + if (process.env.COMPOSABLE_PROMPTS_APIKEY) { + return { + name: 'COMPOSABLE_PROMPTS_APIKEY', + token: process.env.COMPOSABLE_PROMPTS_APIKEY, + }; + } + return undefined; +} + +function readTokenDetails(token: string | undefined, expiresAt?: number): TokenDetails { + if (!token) { + return { present: false }; + } + + const decoded = jwt.decode(token, { json: true }); + if (!decoded || typeof decoded !== 'object') { + return readOpaqueTokenDetails(expiresAt); + } + + const tokenExpiresAt = expiresAt ?? readNumericDate(decoded, 'exp'); + return { + present: true, + type: 'jwt', + expires_at: formatTimestamp(tokenExpiresAt), + expired: tokenExpiresAt === undefined ? undefined : tokenExpiresAt <= Date.now(), + issuer: readStringField(decoded, 'iss'), + subject: readStringField(decoded, 'sub'), + audience: readAudience(decoded), + account: readRefId(decoded, 'account') || readStringField(decoded, 'account_id'), + project: readRefId(decoded, 'project') || readStringField(decoded, 'project_id'), + }; +} + +function readOpaqueTokenDetails(expiresAt?: number): TokenDetails { + return { + present: true, + type: 'opaque', + expires_at: formatTimestamp(expiresAt), + expired: expiresAt === undefined ? undefined : expiresAt <= Date.now(), + }; +} + +function readNumericDate(value: object, key: string): number | undefined { + const field = Reflect.get(value, key); + return typeof field === 'number' ? field * 1000 : undefined; +} + +function readAudience(value: object): string | undefined { + const field = Reflect.get(value, 'aud'); + if (typeof field === 'string') { + return field; + } + if (Array.isArray(field)) { + const first = field.find((candidate) => typeof candidate === 'string'); + return typeof first === 'string' ? first : undefined; + } + return undefined; +} + +function formatTimestamp(value: number | undefined): string | undefined { + return value === undefined ? undefined : new Date(value).toISOString(); +} + function readRefId(value: object, key: string): string | undefined { const field = Reflect.get(value, key); if (typeof field === 'string') { @@ -402,6 +588,63 @@ function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value); } +function printAuthDetails(payload: AuthDetailsPayload) { + console.log('Authentication details'); + console.log(); + printSection('Profile', [ + ['Selected', payload.selected_profile], + ['Account', payload.profile?.account], + ['Project', payload.profile?.project], + ['Config URL', payload.profile?.config_url], + ['Studio server', payload.profile?.studio_server_url], + ['Zeno server', payload.profile?.zeno_server_url], + ['Region', payload.profile?.region], + ]); + printSection('Environment overrides', [ + ['Credential', payload.environment.credential], + ['Studio server', payload.environment.studio_server_url], + ['Zeno server', payload.environment.zeno_server_url], + ['Token server', payload.environment.token_server_url], + ['Project', payload.environment.project], + ]); + printSection('Stored credentials', [ + ['Keyring', payload.keyring_available ? 'available' : 'unavailable'], + ['Access token', formatTokenSummary(payload.stored_credentials?.access_token)], + ['Refresh token', formatTokenSummary(payload.stored_credentials?.refresh_token)], + ['ID token', formatTokenSummary(payload.stored_credentials?.id_token)], + ['OAuth client', payload.stored_credentials?.oauth_client_id], + ['OAuth resource', payload.stored_credentials?.oauth_resource], + ]); + printSection('Active credential', [ + ['Source', payload.active_credential_source], + ['Token', formatTokenSummary(payload.active_token)], + ['Issuer', payload.active_token.issuer], + ['Subject', payload.active_token.subject], + ['Audience', payload.active_token.audience], + ['Account', payload.active_token.account], + ['Project', payload.active_token.project], + ]); +} + +function printSection(title: string, rows: Array<[string, string | undefined]>) { + console.log(colors.bold(title)); + for (const [label, value] of rows) { + console.log(` ${label}: ${value || '-'}`); + } + console.log(); +} + +function formatTokenSummary(details: TokenDetails | undefined): string { + if (!details?.present) { + return 'not stored'; + } + const parts = [details.type || 'token']; + if (details.expires_at) { + parts.push(`${details.expired ? 'expired' : 'expires'} ${details.expires_at}`); + } + return parts.join(', '); +} + export async function tryRefreshToken() { if (!config.current) { console.log("No profile is selected. Run `vertesia profiles use ` to select a profile"); diff --git a/packages/client/src/ProjectsApi.ts b/packages/client/src/ProjectsApi.ts index a97684343..e13e1bf48 100644 --- a/packages/client/src/ProjectsApi.ts +++ b/packages/client/src/ProjectsApi.ts @@ -1,5 +1,5 @@ import { ApiTopic, ClientBase, ServerError } from "@vertesia/api-fetch-client"; -import { AwsConfiguration, CompositeAppConfig, CompositeAppConfigPayload, ExaConfiguration, GithubConfiguration, GladiaConfiguration, ICreateProjectPayload, InCodeTypeDefinition, LinkupConfiguration, MagicPdfConfiguration, Project, ProjectConfiguration, ProjectIntegrationListEntry, ProjectRef, ProjectToolInfo, RenderingTemplateDefinition, RenderingTemplateDefinitionRef, ResendConfiguration, SerperConfiguration, SupportedIntegrations } from "@vertesia/common"; +import { AwsConfiguration, CompositeAppConfig, CompositeAppConfigPayload, ExaConfiguration, GithubConfiguration, GladiaConfiguration, ICreateProjectPayload, InCodeProcessDefinition, InCodeTypeDefinition, LinkupConfiguration, MagicPdfConfiguration, Project, ProjectConfiguration, ProjectIntegrationListEntry, ProjectRef, ProjectToolInfo, RenderingTemplateDefinition, RenderingTemplateDefinitionRef, ResendConfiguration, SerperConfiguration, SupportedIntegrations } from "@vertesia/common"; export default class ProjectsApi extends ApiTopic { constructor(parent: ClientBase) { @@ -65,6 +65,16 @@ export default class ProjectsApi extends ApiTopic { return this.get(`/${projectId}/app-types/${typeId}`); } + listAppProcesses(projectId: string, tag?: string): Promise { + return this.get(`/${projectId}/app-processes`, { + query: { tag } + }); + } + + getAppProcess(projectId: string, processId: string): Promise { + return this.get(`/${projectId}/app-processes/${processId}`); + } + listAppRenderingTemplates(projectId: string, tag?: string): Promise { return this.get(`/${projectId}/app-templates`, { query: { tag } diff --git a/packages/client/src/store/ProcessApi.ts b/packages/client/src/store/ProcessApi.ts index 59e1321bf..4731a2a1b 100644 --- a/packages/client/src/store/ProcessApi.ts +++ b/packages/client/src/store/ProcessApi.ts @@ -1,21 +1,33 @@ import { ApiTopic, ClientBase } from '@vertesia/api-fetch-client'; import { CreateProcessDefinitionPayload, + PublishProcessDefinitionPayload, ProcessDefinition, + RevertProcessDefinitionPayload, UpdateProcessDefinitionPayload, } from '@vertesia/common'; +export interface ListProcessDefinitionsQuery { + status?: string; + process?: string; + limit?: number; + offset?: number; + /** Include every revision/version instead of only the latest head revision. */ + allVersions?: boolean; +} + export class ProcessApi extends ApiTopic { constructor(parent: ClientBase) { super(parent, '/api/v1/processes'); } - list(query?: { status?: string; process?: string; limit?: number; offset?: number }): Promise { + list(query?: ListProcessDefinitionsQuery): Promise { const params: Record = {}; if (query?.status) params.status = query.status; if (query?.process) params.process = query.process; if (query?.limit != null) params.limit = String(query.limit); if (query?.offset != null) params.offset = String(query.offset); + if (query?.allVersions) params.all_versions = 'true'; return this.get('/', { query: params }); } @@ -31,6 +43,18 @@ export class ProcessApi extends ApiTopic { return this.put(`/${id}`, { payload }); } + listVersions(id: string): Promise { + return this.get(`/${id}/versions`); + } + + publish(id: string, payload: PublishProcessDefinitionPayload): Promise { + return this.post(`/${id}/publish`, { payload }); + } + + revert(id: string, payload: RevertProcessDefinitionPayload): Promise { + return this.post(`/${id}/revert`, { payload }); + } + delete(id: string): Promise<{ id: string; count: number }> { return this.del(`/${id}`); } diff --git a/packages/common/src/apps.ts b/packages/common/src/apps.ts index 4fbf82d37..6b3e2b8cb 100644 --- a/packages/common/src/apps.ts +++ b/packages/common/src/apps.ts @@ -1,6 +1,6 @@ import { JSONSchema, ToolDefinition } from "@llumiverse/common"; import { CatalogInteractionRef } from "./interaction.js"; -import { DSLActivityOptions, InCodeTypeDefinition } from "./store/index.js"; +import { DSLActivityOptions, InCodeProcessDefinition, InCodeTypeDefinition } from "./store/index.js"; /** Allowed values for AppUINavItem.preferredSection */ export const PREFERRED_SECTIONS = ["default", "footer", "settings"] as const; @@ -381,7 +381,7 @@ export interface RemoteActivityDefinition { options?: DSLActivityOptions; } -export type AppCapabilities = 'ui' | 'tools' | 'interactions' | 'types' | 'templates'; +export type AppCapabilities = 'ui' | 'tools' | 'interactions' | 'types' | 'processes' | 'templates'; export type AppAvailableIn = 'app_portal' | 'composite_app'; export interface AppManifestData { /** @@ -467,7 +467,8 @@ export interface AppManifestData { * - ui * - tools * - interactions - * - types + * - types + * - processes * - settings * - all (the default if no scope is provided) * You can also use comma-separated values to combine scopes (e.g. "ui,tools"). @@ -595,7 +596,7 @@ export function resolveManifestUrls( } } -export type AppPackageScope = 'ui' | 'tools' | 'interactions' | 'types' | 'templates' | 'settings' | 'widgets' | 'activities' | 'all'; +export type AppPackageScope = 'ui' | 'tools' | 'interactions' | 'types' | 'processes' | 'templates' | 'settings' | 'widgets' | 'activities' | 'all'; export interface AppPackage { /** * The UI configuration of the app @@ -625,6 +626,11 @@ export interface AppPackage { */ types?: InCodeTypeDefinition[]; + /** + * A list of process definitions exposed by the app. + */ + processes?: InCodeProcessDefinition[]; + /** * Templates provided by the app. */ diff --git a/packages/common/src/store/agent-run.ts b/packages/common/src/store/agent-run.ts index 25d2d20b2..b7c7397db 100644 --- a/packages/common/src/store/agent-run.ts +++ b/packages/common/src/store/agent-run.ts @@ -315,6 +315,8 @@ export interface CreateAgentRunPayload, TProperties export interface ProcessRunInputPayload, TSource = RunSource> { process_id?: string; + /** Optional published process version to pin. Defaults to the latest/head revision. */ + process_version?: number; process_definition?: ProcessDefinitionBody; data?: TData; config?: ProcessRunConfig; diff --git a/packages/common/src/store/process.ts b/packages/common/src/store/process.ts index 21c4c4d9e..52e5fdd89 100644 --- a/packages/common/src/store/process.ts +++ b/packages/common/src/store/process.ts @@ -164,6 +164,33 @@ export interface ProcessDefinitionBody { metadata?: ProcessDefinitionMetadata; } +export interface InCodeProcessDefinition { + /** + * Process identifier exposed by an app package. App-local ids are normalized + * by Studio to `app::` when returned to callers. + */ + id: string; + /** Human-readable or app-local process name. */ + name: string; + title?: string; + description?: string; + tags?: string[]; + definition: ProcessDefinitionBody; +} + +export interface ProcessDefinitionRevisionInfo { + /** Direct parent revision id. Omitted for the first revision in a bucket. */ + parent?: string; + /** Root revision id shared by all revisions of the same process definition. */ + root: string; + /** True when this is the latest revision returned by default list/resolve calls. */ + head: boolean; + /** Optional human-readable label for the revision. */ + label?: string; + /** Optional publish note captured when a draft is promoted. */ + comment?: string; +} + export interface ProcessDefinition { id: string; account: string; @@ -172,6 +199,7 @@ export interface ProcessDefinition { description?: string; status: ProcessDefinitionStatus; version: number; + revision?: ProcessDefinitionRevisionInfo; tags?: string[]; definition: ProcessDefinitionBody; created_at: Date; @@ -224,7 +252,14 @@ export interface ProcessState { export interface CreateProcessDefinitionPayload { name: string; description?: string; + /** + * @deprecated Process definitions are created as drafts. Use the publish endpoint + * to create immutable published versions. + */ status?: ProcessDefinitionStatus; + /** + * @deprecated Version is server-owned. Use the publish endpoint to create the next version. + */ version?: number; tags?: string[]; definition: ProcessDefinitionBody; @@ -233,8 +268,32 @@ export interface CreateProcessDefinitionPayload { export interface UpdateProcessDefinitionPayload { name?: string; description?: string; + /** + * @deprecated Status is server-owned. Use publish/archive endpoints instead of updating it directly. + */ status?: ProcessDefinitionStatus; + /** + * @deprecated Version is server-owned. Use the publish endpoint to create the next version. + */ version?: number; tags?: string[]; definition?: ProcessDefinitionBody; } + +export interface PublishProcessDefinitionPayload { + /** Required explicit confirmation from the caller. */ + confirmed: boolean; + /** Optional tags to merge into the published revision. */ + tags?: string[]; + /** Optional human-readable revision label. */ + label?: string; + /** Optional publish note. */ + comment?: string; +} + +export interface RevertProcessDefinitionPayload { + /** Required explicit confirmation from the caller. */ + confirmed: boolean; + /** Optional note explaining why this version is being restored as the draft. */ + comment?: string; +} diff --git a/packages/tools-sdk/src/server.ts b/packages/tools-sdk/src/server.ts index 74d1c425f..9b9b5f3d5 100644 --- a/packages/tools-sdk/src/server.ts +++ b/packages/tools-sdk/src/server.ts @@ -14,6 +14,7 @@ import { createTemplatesRoute } from "./server/templates.js"; import { createWidgetsRoute } from "./server/widgets.js"; import { createPackageRoute } from "./server/app-package.js"; import { createContentTypesRoute } from "./server/content-types.js"; +import { createProcessesRoute } from "./server/processes.js"; // Schema for tool execution payload const ToolExecutionPayloadSchema = z.object({ @@ -96,13 +97,14 @@ export function createToolServer(config: ToolServerConfig): Hono { return c.json({ message: 'Vertesia Tools API', version: '1.0.0', - endpoints: { - tools: allToolEndpoints, - interactions: interactions.map(col => `${prefix}/interactions/${col.name}`), - templates: templates.map(col => `${prefix}/templates/${col.name}`), - activities: activities.map(col => `${prefix}/activities/${col.name}`), - mcp: mcpProviders.map(p => `${prefix}/mcp/${p.name}`), - } + endpoints: { + tools: allToolEndpoints, + interactions: interactions.map(col => `${prefix}/interactions/${col.name}`), + templates: templates.map(col => `${prefix}/templates/${col.name}`), + processes: `${prefix}/processes`, + activities: activities.map(col => `${prefix}/activities/${col.name}`), + mcp: mcpProviders.map(p => `${prefix}/mcp/${p.name}`), + } }); }); @@ -115,6 +117,7 @@ export function createToolServer(config: ToolServerConfig): Hono { createInteractionsRoute(app, `${prefix}/interactions`, config); createTemplatesRoute(app, `${prefix}/templates`, config); createContentTypesRoute(app, `${prefix}/types`, config); + createProcessesRoute(app, `${prefix}/processes`, config); createMcpRoute(app, `${prefix}/mcp`, config); @@ -170,4 +173,3 @@ export function createDevServer(config: ToolServerConfig & { return app; } - diff --git a/packages/tools-sdk/src/server/app-package.ts b/packages/tools-sdk/src/server/app-package.ts index 9429138ac..4ce520bcd 100644 --- a/packages/tools-sdk/src/server/app-package.ts +++ b/packages/tools-sdk/src/server/app-package.ts @@ -1,4 +1,4 @@ -import { AppPackage, AppPackageScope, AppWidgetInfo, CatalogInteractionRef, InCodeTypeDefinition, RemoteActivityDefinition } from "@vertesia/common"; +import { AppPackage, AppPackageScope, AppWidgetInfo, CatalogInteractionRef, InCodeProcessDefinition, InCodeTypeDefinition, RemoteActivityDefinition } from "@vertesia/common"; import { Context, Hono } from "hono"; import { ToolUseContext } from "../types.js"; import { ToolServerConfig } from "./types.js"; @@ -63,6 +63,13 @@ const builders: Record, (pkg: AppPackage, config } pkg.types = allTypes; }, + async processes(pkg: AppPackage, config: ToolServerConfig) { + const allProcesses: InCodeProcessDefinition[] = []; + for (const process of config.processes || []) { + allProcesses.push(process); + } + pkg.processes = allProcesses; + }, async templates(pkg: AppPackage, config: ToolServerConfig) { const basePath = `${config.prefix || '/api'}/templates`; pkg.templates = (config.templates || []).flatMap(coll => @@ -125,6 +132,7 @@ async function handlePackageRequest(c: Context, config: ToolServerConfig) { await builders.tools(pkg, config, c); await builders.interactions(pkg, config, c); await builders.types(pkg, config, c); + await builders.processes(pkg, config, c); await builders.templates(pkg, config, c); await builders.widgets(pkg, config, c); await builders.ui(pkg, config, c); @@ -140,6 +148,9 @@ async function handlePackageRequest(c: Context, config: ToolServerConfig) { if (scopes.has('types')) { await builders.types(pkg, config, c); } + if (scopes.has('processes')) { + await builders.processes(pkg, config, c); + } if (scopes.has('templates')) { await builders.templates(pkg, config, c); } @@ -170,4 +181,4 @@ export function createPackageRoute(app: Hono, basePath: string, config: ToolServ return handlePackageRequest(c, config); }); -} \ No newline at end of file +} diff --git a/packages/tools-sdk/src/server/processes.ts b/packages/tools-sdk/src/server/processes.ts new file mode 100644 index 000000000..078dd29d1 --- /dev/null +++ b/packages/tools-sdk/src/server/processes.ts @@ -0,0 +1,37 @@ +// ================== Process Definition Endpoints ================== + +import { InCodeProcessDefinition } from "@vertesia/common"; +import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { ToolServerConfig } from "./types.js"; + +export function createProcessesRoute(app: Hono, basePath: string, config: ToolServerConfig) { + const { processes = [] } = config; + + app.get(basePath, (c) => { + return c.json({ + title: 'All Processes', + description: 'All available process definitions', + processes, + }); + }); + + app.get(`${basePath}/:name`, (c) => { + const name = c.req.param('name'); + const process = findProcess(processes, name); + if (!process) { + throw new HTTPException(404, { + message: "No process found with name: " + name, + }); + } + return c.json(process); + }); +} + +function findProcess(processes: InCodeProcessDefinition[], name: string): InCodeProcessDefinition | undefined { + return processes.find(process => + process.id === name + || process.name === name + || process.definition.process === name + ); +} diff --git a/packages/tools-sdk/src/server/types.ts b/packages/tools-sdk/src/server/types.ts index 030609fc4..2c5f72155 100644 --- a/packages/tools-sdk/src/server/types.ts +++ b/packages/tools-sdk/src/server/types.ts @@ -6,7 +6,7 @@ import { RenderingTemplateCollection } from "../RenderingTemplateCollection.js"; import { ToolCollection } from "../ToolCollection.js"; import { ToolExecutionPayload } from "../types.js"; import { JSONSchema } from "@llumiverse/common"; -import { AppUIConfig, ProjectConfiguration } from "@vertesia/common"; +import { AppUIConfig, InCodeProcessDefinition, ProjectConfiguration } from "@vertesia/common"; import { ContentTypesCollection } from "../ContentTypesCollection.js"; /** @@ -62,6 +62,10 @@ export interface ToolServerConfig { * Content type collections to expose */ types?: ContentTypesCollection[]; + /** + * Process definitions to expose as app-contributed processes. + */ + processes?: InCodeProcessDefinition[]; /** * Skill collections to expose */ @@ -102,4 +106,3 @@ export interface ToolServerConfig { */ toolFilter?: (projectConfig: ProjectConfiguration) => boolean; } - From a113f546b226c52e311faf92b4d1ea511934b653 Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Sun, 3 May 2026 16:18:38 +0900 Subject: [PATCH 37/75] feat: app dashboards capability + plugin template chrome upgrade Add `'dashboards'` to `AppCapabilities`, wire `AppDashboardDefinition[]` through `ToolServerConfig`, expose them via the `/api/package?scope=dashboards` endpoint, and ship a new `dashboards/` bucket in the plugin template so plugins can contribute read-only Vega-Lite dashboards as `app::`. Plugin template chrome upgrade: - New `PageShell` primitive (consistent header/action layout). - New `CommandPalette` (cmd-K / ctrl-K, route-registry-driven, schema-aware). - New `PersistentAssistant` (route-aware via pluggable getContext, single global launcher, listens for OPEN_ASSISTANT_EVENT). - Typed `PluginRoute[]` registry with `label/icon/sidebarGroup/hideFromNav` metadata; sidebar and command palette read from one source. - 56px frosted top nav with primary action / notifications slots and a single Ask AI launcher. - Active palette tokens (slate-blue default, light + dark) so first paint is themed instead of a stub. - Generator prompts: `THEME_TOKENS` select (slate / indigo / cyan-futuristic / lime) and `ASSISTANT_INTERACTION` text input. Lint + typecheck clean across template UI and tool server. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/common/src/apps.ts | 27 ++- packages/common/src/data-platform.ts | 134 +++++++++++++- packages/tools-sdk/src/server.ts | 4 + packages/tools-sdk/src/server/app-package.ts | 32 +++- packages/tools-sdk/src/server/dashboards.ts | 35 ++++ packages/tools-sdk/src/server/types.ts | 6 +- .../plugin-template/src/tool-server/config.ts | 2 + .../src/tool-server/dashboards/index.ts | 14 ++ .../plugin-template/src/ui/CommandPalette.tsx | 164 ++++++++++++++++++ .../plugin-template/src/ui/PageShell.tsx | 27 +++ .../src/ui/PersistentAssistant.tsx | 150 ++++++++++++++++ .../plugin-template/src/ui/PluginLayout.tsx | 36 ++-- .../plugin-template/src/ui/PluginSidebar.tsx | 124 ++++++++----- .../plugin-template/src/ui/PluginTopNav.tsx | 95 ++++++---- .../plugin-template/src/ui/assistantEvents.ts | 13 ++ templates/plugin-template/src/ui/constants.ts | 3 +- .../src/ui/i18n/locales/en.json | 9 + .../src/ui/i18n/locales/fr.json | 9 + templates/plugin-template/src/ui/index.css | 24 +-- templates/plugin-template/src/ui/pages.tsx | 29 ++-- templates/plugin-template/src/ui/routes.tsx | 43 ++++- .../plugin-template/template.config.json | 36 ++++ 22 files changed, 872 insertions(+), 144 deletions(-) create mode 100644 packages/tools-sdk/src/server/dashboards.ts create mode 100644 templates/plugin-template/src/tool-server/dashboards/index.ts create mode 100644 templates/plugin-template/src/ui/CommandPalette.tsx create mode 100644 templates/plugin-template/src/ui/PageShell.tsx create mode 100644 templates/plugin-template/src/ui/PersistentAssistant.tsx create mode 100644 templates/plugin-template/src/ui/assistantEvents.ts diff --git a/packages/common/src/apps.ts b/packages/common/src/apps.ts index 7420fb22c..e315cd70d 100644 --- a/packages/common/src/apps.ts +++ b/packages/common/src/apps.ts @@ -1,4 +1,5 @@ import { JSONSchema, ToolDefinition } from "@llumiverse/common"; +import type { AppDashboardDefinition } from "./data-platform.js"; import { CatalogInteractionRef } from "./interaction.js"; import { DSLActivityOptions, InCodeProcessDefinition, InCodeTypeDefinition } from "./store/index.js"; @@ -391,7 +392,7 @@ export interface RemoteActivityDefinition { options?: DSLActivityOptions; } -export type AppCapabilities = 'ui' | 'tools' | 'interactions' | 'types' | 'processes' | 'templates'; +export type AppCapabilities = 'ui' | 'tools' | 'interactions' | 'types' | 'processes' | 'templates' | 'dashboards'; export type AppAvailableIn = 'app_portal' | 'composite_app'; export interface AppManifestData { /** @@ -477,8 +478,10 @@ export interface AppManifestData { * - ui * - tools * - interactions - * - types - * - processes + * - types + * - processes + * - templates + * - dashboards * - settings * - all (the default if no scope is provided) * You can also use comma-separated values to combine scopes (e.g. "ui,tools"). @@ -606,7 +609,18 @@ export function resolveManifestUrls( } } -export type AppPackageScope = 'ui' | 'tools' | 'interactions' | 'types' | 'processes' | 'templates' | 'settings' | 'widgets' | 'activities' | 'all'; +export type AppPackageScope = + | 'ui' + | 'tools' + | 'interactions' + | 'types' + | 'processes' + | 'templates' + | 'dashboards' + | 'settings' + | 'widgets' + | 'activities' + | 'all'; export interface AppPackage { /** * The UI configuration of the app @@ -646,6 +660,11 @@ export interface AppPackage { */ templates?: RenderingTemplateDefinitionRef[]; + /** + * Dashboards provided by the app. + */ + dashboards?: AppDashboardDefinition[]; + /** * Widgets provided by the app. */ diff --git a/packages/common/src/data-platform.ts b/packages/common/src/data-platform.ts index edc3bae4d..635c56cd7 100644 --- a/packages/common/src/data-platform.ts +++ b/packages/common/src/data-platform.ts @@ -810,6 +810,99 @@ export interface DashboardQueryParameters { defaults: Record; } +/** + * Elasticsearch DSL supported by dashboard data sources. + * Queries execute through Vertesia Store, so project/security filtering remains server-side. + */ +export interface DashboardElasticsearchDsl { + query?: Record; + aggs?: Record; + size?: number; + from?: number; + sort?: Array>; +} + +/** + * How an Elasticsearch DSL result should be converted into Vega rows. + */ +export type DashboardElasticsearchResultMapping = + | { + type: 'hits'; + } + | { + type: 'aggregation_buckets'; + /** Dot path under `aggregations` that contains a `buckets` array. */ + path: string; + /** Output field name for bucket key. Defaults to `key`. */ + keyField?: string; + /** Output field name for doc count. Defaults to `doc_count`. */ + countField?: string; + }; + +/** + * Dashboard data source backed by a Data Platform SQL query. + */ +export interface DashboardSqlDataSource { + kind: 'data_sql'; + /** SQL query that returns all rows for the dashboard. */ + query: string; + /** Maximum rows to return from the query. */ + queryLimit?: number; + /** Default values for SQL {{param}} placeholders. */ + queryParameters?: Record; +} + +/** + * Dashboard data source backed by Vertesia Store Elasticsearch DSL. + */ +export interface DashboardStoreElasticsearchDataSource { + kind: 'store_es_dsl'; + /** Elasticsearch DSL query executed through the secured Store query API. */ + dsl: DashboardElasticsearchDsl; + /** Result mapping. Defaults to `hits`. */ + result?: DashboardElasticsearchResultMapping; +} + +/** + * Data source for a Vega dashboard. + */ +export type DashboardDataSource = DashboardSqlDataSource | DashboardStoreElasticsearchDataSource; + +/** + * Dashboard definition contributed by an app package. + * + * App dashboard IDs are local to the app. The platform exposes them as + * `app::` when listing or retrieving dashboards. + */ +export interface AppDashboardDefinition { + /** Local app dashboard ID. */ + id: string; + /** Machine-friendly dashboard name. Defaults to `id`. */ + name?: string; + /** Display title. Defaults to `name` or `id`. */ + title?: string; + /** User-facing description. */ + description?: string; + /** Tags for discovery and filtering. */ + tags?: string[]; + /** Data source used to populate Vega `data.values`. */ + dataSource?: DashboardDataSource; + /** SQL query shortcut for app dashboards backed by data stores. */ + query?: string; + /** Maximum SQL rows to return. */ + queryLimit?: number; + /** Default values for SQL {{param}} placeholders. */ + queryParameters?: Record; + /** Complete Vega-Lite specification for the dashboard. */ + spec?: Record; + /** Legacy named SQL queries. */ + queries?: DashboardQuery[]; + /** Legacy panel definitions. */ + panels?: DashboardPanel[]; + /** Legacy dashboard layout. */ + layout?: DashboardLayout; +} + /** * Summary view of a dashboard (for listings). */ @@ -826,15 +919,22 @@ export interface DashboardItem extends BaseObject { last_rendered_at?: string; /** Tags for organization */ tags: string[]; + /** Source of the dashboard definition. Defaults to stored dashboards. */ + source?: 'stored' | 'app'; + /** App name when `source` is `app`. */ + app_name?: string; + /** App dashboards are read-only until cloned into a stored dashboard. */ + readonly?: boolean; } /** * Full dashboard with SQL query and Vega-Lite specification. * * **New architecture (v2):** - * - Single `query` field with SQL (use JOINs/CTEs for complex data needs) + * - `dataSource` field with either SQL or Store Elasticsearch DSL * - Single `spec` field with complete Vega-Lite spec (vconcat/hconcat for multiple panels) * - Cross-panel interactivity via Vega selections + * - Legacy top-level `query` fields are treated as a SQL data source * * **Legacy architecture (v1, deprecated):** * - Multiple `queries` with named data sources @@ -844,18 +944,26 @@ export interface DashboardItem extends BaseObject { */ export interface Dashboard extends DashboardItem { // ============= New architecture (v2) ============= + /** + * Data source used to populate Vega `data.values`. + * When omitted, top-level `query` is treated as a SQL data source for backwards compatibility. + */ + dataSource?: DashboardDataSource; /** * SQL query that returns all data for the dashboard. * Use JOINs, CTEs, or UNION ALL to combine data from multiple tables. * Can include {{param_name}} placeholders for dynamic values. + * @deprecated Use `dataSource: { kind: 'data_sql', query }` instead. */ query?: string; /** * Maximum rows to return from the query (default: 10000). + * @deprecated Use `dataSource.queryLimit` instead. */ queryLimit?: number; /** * Default values for SQL parameters. + * @deprecated Use `dataSource.queryParameters` instead. */ queryParameters?: Record; /** @@ -889,15 +997,17 @@ export interface Dashboard extends DashboardItem { /** * Payload for creating a new dashboard. - * Requires query (SQL) and spec (Vega-Lite). + * Requires a data source and spec (Vega-Lite). */ export interface CreateDashboardPayload { /** Dashboard name (unique within store) */ name: string; /** Dashboard summary */ summary?: string; - /** SQL query that returns all data for the dashboard */ - query: string; + /** Data source used to populate Vega `data.values`. */ + dataSource?: DashboardDataSource; + /** SQL query that returns all data for the dashboard. Deprecated shortcut for a SQL data source. */ + query?: string; /** Maximum rows to return from the query (default: 10000) */ queryLimit?: number; /** Default values for SQL {{param}} placeholders */ @@ -914,7 +1024,9 @@ export interface UpdateDashboardPayload { name?: string; /** Dashboard summary */ summary?: string; - /** SQL query that returns all data for the dashboard */ + /** Data source used to populate Vega `data.values`. */ + dataSource?: DashboardDataSource; + /** SQL query that returns all data for the dashboard. Deprecated shortcut for a SQL data source. */ query?: string; /** Maximum rows to return from the query (default: 10000) */ queryLimit?: number; @@ -931,6 +1043,8 @@ export interface UpdateDashboardPayload { */ export interface PreviewDashboardPayload { // ============= New architecture (v2) ============= + /** Data source used to populate Vega `data.values`. */ + dataSource?: DashboardDataSource; /** SQL query that returns all data for the dashboard */ query?: string; /** Maximum rows to return from the query (default: 10000) */ @@ -994,6 +1108,16 @@ export interface DashboardVersion { version_number: number; /** Commit message describing the change */ message: string; + /** Snapshot of v2 data source at this version */ + dataSource?: DashboardDataSource; + /** Snapshot of v2 SQL query at this version */ + query?: string; + /** Snapshot of v2 query limit at this version */ + queryLimit?: number; + /** Snapshot of v2 query parameters at this version */ + queryParameters?: Record; + /** Snapshot of v2 Vega-Lite spec at this version */ + spec?: Record; /** Snapshot of queries at this version */ queries: DashboardQuery[]; /** Snapshot of panels at this version */ diff --git a/packages/tools-sdk/src/server.ts b/packages/tools-sdk/src/server.ts index 9b9b5f3d5..38f70691f 100644 --- a/packages/tools-sdk/src/server.ts +++ b/packages/tools-sdk/src/server.ts @@ -14,6 +14,7 @@ import { createTemplatesRoute } from "./server/templates.js"; import { createWidgetsRoute } from "./server/widgets.js"; import { createPackageRoute } from "./server/app-package.js"; import { createContentTypesRoute } from "./server/content-types.js"; +import { createDashboardsRoute } from "./server/dashboards.js"; import { createProcessesRoute } from "./server/processes.js"; // Schema for tool execution payload @@ -51,6 +52,7 @@ export function createToolServer(config: ToolServerConfig): Hono { skills = [], templates = [], activities = [], + dashboards = [], mcpProviders = [], disableHtml = false, } = config; @@ -102,6 +104,7 @@ export function createToolServer(config: ToolServerConfig): Hono { interactions: interactions.map(col => `${prefix}/interactions/${col.name}`), templates: templates.map(col => `${prefix}/templates/${col.name}`), processes: `${prefix}/processes`, + dashboards: dashboards.length > 0 ? `${prefix}/dashboards` : undefined, activities: activities.map(col => `${prefix}/activities/${col.name}`), mcp: mcpProviders.map(p => `${prefix}/mcp/${p.name}`), } @@ -118,6 +121,7 @@ export function createToolServer(config: ToolServerConfig): Hono { createTemplatesRoute(app, `${prefix}/templates`, config); createContentTypesRoute(app, `${prefix}/types`, config); createProcessesRoute(app, `${prefix}/processes`, config); + createDashboardsRoute(app, `${prefix}/dashboards`, config); createMcpRoute(app, `${prefix}/mcp`, config); diff --git a/packages/tools-sdk/src/server/app-package.ts b/packages/tools-sdk/src/server/app-package.ts index 4ce520bcd..ee318ab21 100644 --- a/packages/tools-sdk/src/server/app-package.ts +++ b/packages/tools-sdk/src/server/app-package.ts @@ -1,4 +1,13 @@ -import { AppPackage, AppPackageScope, AppWidgetInfo, CatalogInteractionRef, InCodeProcessDefinition, InCodeTypeDefinition, RemoteActivityDefinition } from "@vertesia/common"; +import type { + AppDashboardDefinition, + AppPackage, + AppPackageScope, + AppWidgetInfo, + CatalogInteractionRef, + InCodeProcessDefinition, + InCodeTypeDefinition, + RemoteActivityDefinition, +} from "@vertesia/common"; import { Context, Hono } from "hono"; import { ToolUseContext } from "../types.js"; import { ToolServerConfig } from "./types.js"; @@ -7,7 +16,9 @@ function getRequestPayload(c: Context): Promise { return c.req.method === "POST" ? c.req.json() : Promise.resolve(undefined); } -const builders: Record, (pkg: AppPackage, config: ToolServerConfig, c: Context) => Promise> = { +type AppPackageBuilder = (pkg: AppPackage, config: ToolServerConfig, c: Context) => Promise; + +const builders: Record, AppPackageBuilder> = { async tools(pkg: AppPackage, config: ToolServerConfig, c: Context) { const { tools: toolCollections = [], skills: skillCollections = [] } = config; @@ -79,6 +90,19 @@ const builders: Record, (pkg: AppPackage, config })) ); }, + async dashboards(pkg: AppPackage, config: ToolServerConfig) { + const seen = new Set(); + const dashboards: AppDashboardDefinition[] = []; + for (const dashboard of config.dashboards || []) { + if (seen.has(dashboard.id)) { + console.warn(`[app-package] Duplicate dashboard id "${dashboard.id}", skipping`); + continue; + } + seen.add(dashboard.id); + dashboards.push(dashboard); + } + pkg.dashboards = dashboards; + }, async widgets(pkg: AppPackage, config: ToolServerConfig) { const { skills: skillCollections = [] } = config; const widgets: Record = {}; @@ -134,6 +158,7 @@ async function handlePackageRequest(c: Context, config: ToolServerConfig) { await builders.types(pkg, config, c); await builders.processes(pkg, config, c); await builders.templates(pkg, config, c); + await builders.dashboards(pkg, config, c); await builders.widgets(pkg, config, c); await builders.ui(pkg, config, c); await builders.settings(pkg, config, c); @@ -154,6 +179,9 @@ async function handlePackageRequest(c: Context, config: ToolServerConfig) { if (scopes.has('templates')) { await builders.templates(pkg, config, c); } + if (scopes.has('dashboards')) { + await builders.dashboards(pkg, config, c); + } if (scopes.has('widgets')) { await builders.widgets(pkg, config, c); } diff --git a/packages/tools-sdk/src/server/dashboards.ts b/packages/tools-sdk/src/server/dashboards.ts new file mode 100644 index 000000000..a7ecf9096 --- /dev/null +++ b/packages/tools-sdk/src/server/dashboards.ts @@ -0,0 +1,35 @@ +import { AppDashboardDefinition } from "@vertesia/common"; +import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { ToolServerConfig } from "./types.js"; + +export function createDashboardsRoute(app: Hono, basePath: string, config: ToolServerConfig) { + const { dashboards = [] } = config; + + app.get(basePath, (c) => { + return c.json({ + title: 'All Dashboards', + description: 'All available dashboard definitions', + dashboards, + }); + }); + + app.get(`${basePath}/:id`, (c) => { + const id = c.req.param('id'); + const dashboard = findDashboard(dashboards, id); + if (!dashboard) { + throw new HTTPException(404, { + message: "No dashboard found with id: " + id, + }); + } + return c.json(dashboard); + }); +} + +function findDashboard(dashboards: AppDashboardDefinition[], id: string): AppDashboardDefinition | undefined { + return dashboards.find(dashboard => + dashboard.id === id + || dashboard.name === id + || dashboard.title === id + ); +} diff --git a/packages/tools-sdk/src/server/types.ts b/packages/tools-sdk/src/server/types.ts index 2c5f72155..f8706e426 100644 --- a/packages/tools-sdk/src/server/types.ts +++ b/packages/tools-sdk/src/server/types.ts @@ -6,7 +6,7 @@ import { RenderingTemplateCollection } from "../RenderingTemplateCollection.js"; import { ToolCollection } from "../ToolCollection.js"; import { ToolExecutionPayload } from "../types.js"; import { JSONSchema } from "@llumiverse/common"; -import { AppUIConfig, InCodeProcessDefinition, ProjectConfiguration } from "@vertesia/common"; +import { AppDashboardDefinition, AppUIConfig, InCodeProcessDefinition, ProjectConfiguration } from "@vertesia/common"; import { ContentTypesCollection } from "../ContentTypesCollection.js"; /** @@ -66,6 +66,10 @@ export interface ToolServerConfig { * Process definitions to expose as app-contributed processes. */ processes?: InCodeProcessDefinition[]; + /** + * Dashboard definitions to expose as app-contributed dashboards. + */ + dashboards?: AppDashboardDefinition[]; /** * Skill collections to expose */ diff --git a/templates/plugin-template/src/tool-server/config.ts b/templates/plugin-template/src/tool-server/config.ts index 6457e3ed1..aa8cf867f 100644 --- a/templates/plugin-template/src/tool-server/config.ts +++ b/templates/plugin-template/src/tool-server/config.ts @@ -1,5 +1,6 @@ import { ToolServerConfig } from "@vertesia/tools-sdk"; import { activities } from "./activities/index.js"; +import { dashboards } from "./dashboards/index.js"; import { interactions } from "./interactions/index.js"; import { mcpProviders } from "./mcp/index.js"; import { skills } from "./skills/index.js"; @@ -21,6 +22,7 @@ export const ServerConfig = { skills, templates, mcpProviders, + dashboards, uiConfig: { isolation: "shadow", src: "/lib/plugin.js", diff --git a/templates/plugin-template/src/tool-server/dashboards/index.ts b/templates/plugin-template/src/tool-server/dashboards/index.ts new file mode 100644 index 000000000..79ad0b3c7 --- /dev/null +++ b/templates/plugin-template/src/tool-server/dashboards/index.ts @@ -0,0 +1,14 @@ +import type { AppDashboardDefinition } from "@vertesia/common"; + +/** + * Dashboards exposed by this plugin. + * + * Each dashboard renders as `app::` in the host platform — read-only + * until a user clones it into a stored dashboard. Use `dataSource: { type: "store_es_dsl", ... }` + * to ship business dashboards over Store/ES data without per-project records. + * + * Add a definition object per dashboard, or import from sibling files. + * + * @see AppDashboardDefinition in @vertesia/common + */ +export const dashboards: AppDashboardDefinition[] = []; diff --git a/templates/plugin-template/src/ui/CommandPalette.tsx b/templates/plugin-template/src/ui/CommandPalette.tsx new file mode 100644 index 000000000..fe15a8d19 --- /dev/null +++ b/templates/plugin-template/src/ui/CommandPalette.tsx @@ -0,0 +1,164 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Command, Search } from "lucide-react"; +import { Modal } from "@vertesia/ui/core"; +import { useUITranslation } from "@vertesia/ui/i18n"; +import { useNavigate } from "@vertesia/ui/router"; +import { routes } from "./routes"; +import type { PluginRoute } from "./routes"; + +interface PaletteItem { + path: string; + label: string; + icon?: PluginRoute["icon"]; +} + +function isMacLike(): boolean { + if (typeof navigator === "undefined") return false; + return /Mac|iPhone|iPad|iPod/.test(navigator.platform); +} + +function buildItems(t: (key: string) => string): PaletteItem[] { + return routes + .filter(route => !route.hideFromNav && route.label) + .map(route => ({ + path: route.path, + label: route.label!.includes(".") ? t(route.label!) : route.label!, + icon: route.icon, + })); +} + +export function CommandPaletteTrigger({ onOpen }: { onOpen: () => void }) { + const { t } = useUITranslation(); + const shortcut = isMacLike() ? "⌘K" : "Ctrl+K"; + return ( + + ); +} + +export function CommandPalette() { + const { t } = useUITranslation(); + const navigate = useNavigate(); + const [isOpen, setIsOpen] = useState(false); + const [query, setQuery] = useState(""); + const [activeIndex, setActiveIndex] = useState(0); + const inputRef = useRef(null); + + const items = useMemo(() => buildItems(t), [t]); + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return items; + return items.filter(item => item.label.toLowerCase().includes(q)); + }, [items, query]); + + const open = useCallback(() => { + setQuery(""); + setActiveIndex(0); + setIsOpen(true); + }, []); + + const close = useCallback(() => setIsOpen(false), []); + + useEffect(() => { + const handler = (event: KeyboardEvent) => { + const isToggle = (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k"; + if (isToggle) { + event.preventDefault(); + setIsOpen(prev => !prev); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, []); + + useEffect(() => { + if (isOpen) { + const id = window.setTimeout(() => inputRef.current?.focus(), 0); + return () => window.clearTimeout(id); + } + }, [isOpen]); + + const handleQueryChange = useCallback((value: string) => { + setQuery(value); + setActiveIndex(0); + }, []); + + const choose = useCallback((item: PaletteItem) => { + navigate(item.path); + close(); + }, [navigate, close]); + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "ArrowDown") { + event.preventDefault(); + setActiveIndex(i => Math.min(i + 1, filtered.length - 1)); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setActiveIndex(i => Math.max(i - 1, 0)); + } else if (event.key === "Enter") { + event.preventDefault(); + const item = filtered[activeIndex]; + if (item) choose(item); + } else if (event.key === "Escape") { + event.preventDefault(); + close(); + } + }; + + return ( + <> + + +
+
+ + handleQueryChange(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={t("nav.commandPalettePlaceholder")} + className="flex-1 bg-transparent text-sm outline-none" + /> +
+
    + {filtered.length === 0 && ( +
  • + {t("nav.commandPaletteNoResults")} +
  • + )} + {filtered.map((item, index) => { + const Icon = item.icon; + const isActive = index === activeIndex; + return ( +
  • + +
  • + ); + })} +
+
+
+ + ); +} diff --git a/templates/plugin-template/src/ui/PageShell.tsx b/templates/plugin-template/src/ui/PageShell.tsx new file mode 100644 index 000000000..6fb326739 --- /dev/null +++ b/templates/plugin-template/src/ui/PageShell.tsx @@ -0,0 +1,27 @@ +import type { ReactNode } from "react"; + +interface PageShellProps { + title: string; + description?: string; + action?: ReactNode; + children: ReactNode; +} + +export function PageShell({ title, description, action, children }: PageShellProps) { + return ( +
+
+
+
+

{title}

+ {description && ( +

{description}

+ )} +
+ {action} +
+ {children} +
+
+ ); +} diff --git a/templates/plugin-template/src/ui/PersistentAssistant.tsx b/templates/plugin-template/src/ui/PersistentAssistant.tsx new file mode 100644 index 000000000..53f302abc --- /dev/null +++ b/templates/plugin-template/src/ui/PersistentAssistant.tsx @@ -0,0 +1,150 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Bot, X } from "lucide-react"; +import { Button, SidePanel } from "@vertesia/ui/core"; +import { ModernAgentConversation } from "@vertesia/ui/features"; +import { useUITranslation } from "@vertesia/ui/i18n"; +import { useLocation } from "@vertesia/ui/router"; +import { useUserSession } from "@vertesia/ui/session"; +import type { CreateAgentRunPayload } from "@vertesia/common"; +import { ASSISTANT_INTERACTION } from "./constants"; +import { OPEN_ASSISTANT_EVENT } from "./assistantEvents"; +import type { OpenAssistantDetail } from "./assistantEvents"; + +/** + * Route-aware assistant context. Replace this builder for your plugin to + * surface relevant ids (objectId, typeId, selectedObjectIds, etc.) to agent + * runs. The default extracts the first path segment as `scope`. + */ +export interface AssistantContext { + scope: string; + route: string; + [key: string]: unknown; +} + +function defaultContext(pathname: string): AssistantContext { + const segments = pathname.split("/").filter(Boolean); + return { scope: segments[0] || "home", route: pathname }; +} + +interface PersistentAssistantProps { + /** + * Build a route-aware context that gets passed to every agent run started + * from the assistant. Override per plugin to include entity ids. + */ + getContext?: (pathname: string) => AssistantContext; + /** + * Tag every agent run started from the assistant. Use this to scope + * Activity tabs and personalization queries to your plugin. + */ + appTag?: string; + /** Width of the side panel in pixels. */ + panelWidth?: number; + /** Initial assistant interaction id. Defaults to `ASSISTANT_INTERACTION`. */ + interaction?: string; +} + +export function PersistentAssistant({ + getContext = defaultContext, + appTag = "plugin-app", + panelWidth = 560, + interaction = ASSISTANT_INTERACTION, +}: PersistentAssistantProps) { + const { t } = useUITranslation(); + const { client, project, account, user } = useUserSession(); + const location = useLocation(); + const [isOpen, setIsOpen] = useState(false); + const [agentRunId, setAgentRunId] = useState(); + const [suggestedMessage, setSuggestedMessage] = useState(); + + const context = useMemo(() => getContext(location.pathname), [getContext, location.pathname]); + + useEffect(() => { + const open = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (detail?.agentRunId) { + setAgentRunId(detail.agentRunId); + } + setSuggestedMessage(detail?.initialMessage); + setIsOpen(true); + }; + window.addEventListener(OPEN_ASSISTANT_EVENT, open); + return () => window.removeEventListener(OPEN_ASSISTANT_EVENT, open); + }, []); + + const startWorkflow = useCallback(async (initialMessage?: string) => { + const payload: CreateAgentRunPayload = { + interaction, + interactive: true, + data: { + user_prompt: initialMessage || "", + app: appTag, + context, + project_name: project?.name, + account_name: account?.name, + }, + started_by: user?.sub, + tags: [appTag, "user-assistant", context.scope], + }; + const result = await client.agents.start(payload); + if (result?.id) { + setAgentRunId(result.id); + return { agent_run_id: result.id }; + } + return undefined; + }, [interaction, appTag, context, project?.name, account?.name, user?.sub, client.agents]); + + const handleReset = useCallback(() => setAgentRunId(undefined), []); + + return ( + <> + {!isOpen && ( + + )} + setIsOpen(false)} + title={( +
+ + {t("nav.pluginAssistant")} +
+ )} + panelWidth={panelWidth} + contentClassName="flex-1 flex flex-col overflow-hidden min-h-0" + > +
+ {t("nav.assistantContext", { scope: context.scope, route: context.route })} + {agentRunId && ( + + )} +
+
+ +
+
+ + ); +} diff --git a/templates/plugin-template/src/ui/PluginLayout.tsx b/templates/plugin-template/src/ui/PluginLayout.tsx index c8cf36d98..6cc4e55d3 100644 --- a/templates/plugin-template/src/ui/PluginLayout.tsx +++ b/templates/plugin-template/src/ui/PluginLayout.tsx @@ -1,27 +1,31 @@ -import { AppLayout } from '@vertesia/ui/layout'; -import { NestedNavigationContext, useRouterBasePath } from '@vertesia/ui/router'; -import { PluginSidebar } from './PluginSidebar'; -import { PluginTopNav } from './PluginTopNav'; +import { AppLayout } from "@vertesia/ui/layout"; +import { NestedNavigationContext, useRouterBasePath } from "@vertesia/ui/router"; +import { PersistentAssistant } from "./PersistentAssistant"; +import { PluginSidebar } from "./PluginSidebar"; +import { PluginTopNav } from "./PluginTopNav"; interface PluginLayoutProps { children: React.ReactNode; } export function PluginLayout({ children }: PluginLayoutProps) { - const sidebarBg = 'bg-sidebar text-sidebar-foreground border-r border-sidebar-border w-full'; + const sidebarBg = "bg-sidebar text-sidebar-foreground border-r border-sidebar-border w-full"; const basePath = useRouterBasePath(); return ( - - - - )} - sidebarClassName={sidebarBg} - mainNav={} - > - {children} - + <> + + + + )} + sidebarClassName={sidebarBg} + mainNav={} + > + {children} + + + ); } diff --git a/templates/plugin-template/src/ui/PluginSidebar.tsx b/templates/plugin-template/src/ui/PluginSidebar.tsx index b548a80c1..dfcac3479 100644 --- a/templates/plugin-template/src/ui/PluginSidebar.tsx +++ b/templates/plugin-template/src/ui/PluginSidebar.tsx @@ -1,17 +1,41 @@ -import { useEffect, useMemo, useState } from 'react'; -import { ModeToggle } from '@vertesia/ui/core'; -import { useUITranslation } from '@vertesia/ui/i18n'; -import { SidebarSection, useSidebarToggle } from '@vertesia/ui/layout'; -import { useLocation, useRouterBasePath } from '@vertesia/ui/router'; -import { useUserSession } from '@vertesia/ui/session'; -import { HomeIcon, MessageSquare, PlusCircle } from 'lucide-react'; -import type { AgentRunResponse, WorkflowRun } from '@vertesia/common'; -import { AppSidebarItem } from './AppSidebarItem'; -import { ASSISTANT_INTERACTION } from './constants'; +import { useEffect, useMemo, useState } from "react"; +import { ModeToggle } from "@vertesia/ui/core"; +import { useUITranslation } from "@vertesia/ui/i18n"; +import { SidebarSection, useSidebarToggle } from "@vertesia/ui/layout"; +import { useLocation, useRouterBasePath } from "@vertesia/ui/router"; +import { useUserSession } from "@vertesia/ui/session"; +import { MessageSquare } from "lucide-react"; +import type { AgentRunResponse, WorkflowRun } from "@vertesia/common"; +import { AppSidebarItem } from "./AppSidebarItem"; +import { ASSISTANT_INTERACTION } from "./constants"; +import { routes } from "./routes"; +import type { PluginRoute } from "./routes"; -function toWorkflowRun(run: AgentRunResponse): WorkflowRun { - const isAgentRun = run.run_kind === 'agent'; +function isCurrent(path: string, basePath: string, to: string): boolean { + const fullPath = `${basePath}${to === "/" ? "" : to}`; + return path === fullPath || path.startsWith(`${fullPath}/`); +} + +function navRoutes(): PluginRoute[] { + return routes.filter(route => !route.hideFromNav && route.label && route.icon); +} +function groupNavRoutes(items: PluginRoute[]): Array<{ title?: string; items: PluginRoute[] }> { + const groups = new Map(); + const order: string[] = []; + for (const item of items) { + const key = item.sidebarGroup ?? ""; + if (!groups.has(key)) { + groups.set(key, []); + order.push(key); + } + groups.get(key)!.push(item); + } + return order.map(key => ({ title: key || undefined, items: groups.get(key)! })); +} + +function toWorkflowRun(run: AgentRunResponse): WorkflowRun { + const isAgentRun = run.run_kind === "agent"; return { run_id: run.id, workflow_id: run.workflow_id, @@ -29,14 +53,12 @@ function toWorkflowRun(run: AgentRunResponse): WorkflowRun { function getConversationLabel(conv: WorkflowRun, t: (key: string) => string): string { if (conv.topic) return conv.topic; - // input is not populated by listConversations, but check anyway for forward compat const prompt = conv.input?.data?.user_prompt; - if (typeof prompt === 'string' && prompt.trim()) return prompt.trim(); - // Fall back to a formatted date/time + if (typeof prompt === "string" && prompt.trim()) return prompt.trim(); if (conv.started_at) { - return new Date(conv.started_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + return new Date(conv.started_at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } - return t('nav.conversation'); + return t("nav.conversation"); } function getDateLabel(date: Date, t: (key: string) => string): string { @@ -45,9 +67,8 @@ function getDateLabel(date: Date, t: (key: string) => string): string { const yesterday = new Date(today); yesterday.setDate(yesterday.getDate() - 1); const target = new Date(date.getFullYear(), date.getMonth(), date.getDate()); - - if (target.getTime() === today.getTime()) return t('nav.today'); - if (target.getTime() === yesterday.getTime()) return t('nav.yesterday'); + if (target.getTime() === today.getTime()) return t("nav.today"); + if (target.getTime() === yesterday.getTime()) return t("nav.yesterday"); return date.toLocaleDateString(); } @@ -58,7 +79,7 @@ interface GroupedConversations { function groupByDate(conversations: WorkflowRun[], t: (key: string) => string): GroupedConversations[] { const groups: GroupedConversations[] = []; - let currentLabel = ''; + let currentLabel = ""; for (const conv of conversations) { const date = conv.started_at ? new Date(conv.started_at) : new Date(); const label = getDateLabel(date, t); @@ -72,7 +93,16 @@ function groupByDate(conversations: WorkflowRun[], t: (key: string) => string): return groups; } -export function PluginSidebar() { +interface PluginSidebarProps { + /** + * Whether to render the recent agent conversations section under the + * primary nav. Useful for chat-style plugins; turn off for archetypes + * that don't surface conversation history (analytics, forms, queues). + */ + showConversations?: boolean; +} + +export function PluginSidebar({ showConversations = true }: PluginSidebarProps) { const { t } = useUITranslation(); const path = useLocation().pathname; const basePath = useRouterBasePath(); @@ -80,40 +110,44 @@ export function PluginSidebar() { const { client } = useUserSession(); const [conversations, setConversations] = useState([]); + const groupedNav = useMemo(() => groupNavRoutes(navRoutes()), []); + useEffect(() => { + if (!showConversations) return; client.agents.list({ interaction: ASSISTANT_INTERACTION, limit: 20, - sort: 'started_at', - order: 'desc', + sort: "started_at", + order: "desc", }).then(response => setConversations(response.items.map(toWorkflowRun))); - }, [client]); + }, [client, showConversations]); - const grouped = useMemo(() => groupByDate(conversations, t), [conversations, t]); + const groupedConversations = useMemo(() => groupByDate(conversations, t), [conversations, t]); return (
); } diff --git a/templates/plugin-template/src/ui/assistantEvents.ts b/templates/plugin-template/src/ui/assistantEvents.ts new file mode 100644 index 000000000..9cb760659 --- /dev/null +++ b/templates/plugin-template/src/ui/assistantEvents.ts @@ -0,0 +1,13 @@ +export const OPEN_ASSISTANT_EVENT = "plugin:open-assistant"; + +export interface OpenAssistantDetail { + initialMessage?: string; + agentRunId?: string; +} + +export function openAssistant(initialMessage?: string, agentRunId?: string): void { + const event = new CustomEvent(OPEN_ASSISTANT_EVENT, { + detail: { initialMessage, agentRunId }, + }); + window.dispatchEvent(event); +} diff --git a/templates/plugin-template/src/ui/constants.ts b/templates/plugin-template/src/ui/constants.ts index 46cac4531..ca3cf3eef 100644 --- a/templates/plugin-template/src/ui/constants.ts +++ b/templates/plugin-template/src/ui/constants.ts @@ -1,2 +1,3 @@ // Format: app::: -export const ASSISTANT_INTERACTION = "sys:GeneralAgent"; +// The literal below is replaced by the generator from the `ASSISTANT_INTERACTION` prompt. +export const ASSISTANT_INTERACTION = "TEMPLATE__ASSISTANT_INTERACTION"; diff --git a/templates/plugin-template/src/ui/i18n/locales/en.json b/templates/plugin-template/src/ui/i18n/locales/en.json index fcb6feb0c..4ba767b09 100644 --- a/templates/plugin-template/src/ui/i18n/locales/en.json +++ b/templates/plugin-template/src/ui/i18n/locales/en.json @@ -6,9 +6,18 @@ "access.selectAccount": "Select Account", "access.selectProject": "Select Project", "access.switchPrompt": "Switch to a different account or project to access this app.", + "nav.askAi": "Ask AI", + "nav.assistantContext": "Context: {{scope}} · {{route}}", + "nav.assistantInitialPrompt": "Ask about this workspace, your data, or a workflow.", + "nav.assistantPlaceholder": "Ask anything…", + "nav.commandPaletteNoResults": "No matching pages.", + "nav.commandPalettePlaceholder": "Search or jump to…", "nav.conversation": "Conversation", "nav.home": "Home", + "nav.newAssistantSession": "Start a new assistant session", "nav.newChat": "New Chat", + "nav.newSession": "New", + "nav.openAssistant": "Open assistant", "nav.pluginAssistant": "Plugin Assistant", "nav.signOut": "Sign out", "nav.templateDescription": "This is the plugin template. Use it as a starting point to build your own plugin UI.", diff --git a/templates/plugin-template/src/ui/i18n/locales/fr.json b/templates/plugin-template/src/ui/i18n/locales/fr.json index a41b25da0..1ed9da298 100644 --- a/templates/plugin-template/src/ui/i18n/locales/fr.json +++ b/templates/plugin-template/src/ui/i18n/locales/fr.json @@ -6,9 +6,18 @@ "access.selectAccount": "Sélectionner un compte", "access.selectProject": "Sélectionner un projet", "access.switchPrompt": "Changez de compte ou de projet pour accéder à cette application.", + "nav.askAi": "Demander à l'IA", + "nav.assistantContext": "Contexte : {{scope}} · {{route}}", + "nav.assistantInitialPrompt": "Posez une question sur cet espace, vos données ou un processus.", + "nav.assistantPlaceholder": "Posez votre question…", + "nav.commandPaletteNoResults": "Aucune page correspondante.", + "nav.commandPalettePlaceholder": "Rechercher ou aller à…", "nav.conversation": "Conversation", "nav.home": "Accueil", + "nav.newAssistantSession": "Démarrer une nouvelle session", "nav.newChat": "Nouvelle conversation", + "nav.newSession": "Nouvelle", + "nav.openAssistant": "Ouvrir l'assistant", "nav.pluginAssistant": "Assistant du plugin", "nav.signOut": "Se déconnecter", "nav.templateDescription": "Ceci est le modèle de plugin. Utilisez-le comme point de départ pour créer votre propre interface de plugin.", diff --git a/templates/plugin-template/src/ui/index.css b/templates/plugin-template/src/ui/index.css index b93b80213..20833e0a1 100644 --- a/templates/plugin-template/src/ui/index.css +++ b/templates/plugin-template/src/ui/index.css @@ -9,25 +9,11 @@ @source "../../node_modules/@vertesia/tools-admin-ui/src"; /* - * Theme Customization + * Theme Customization — generated from the `THEME_TOKENS` prompt during scaffolding. * - * Override the default Vertesia theme colors below. Values use oklch(lightness chroma hue). - * See https://oklch.com for a color picker. - * These apply globally — plugin pages and the admin UI will both use the custom theme. - * Uncomment and adjust to match your brand. + * Adjust to match your brand. Values use oklch(lightness chroma hue). + * See https://oklch.com for a color picker. To switch the app to dark by + * default, also add class="dark" on in index.html. */ -/* :root { - --primary: oklch(55% 0.200 145); - --primary-background: oklch(97% 0.020 145); - --secondary: oklch(35% 0.050 280); - --secondary-background: oklch(95% 0.010 280); -} - -.dark { - --primary: oklch(72% 0.190 145); - --primary-background: oklch(25% 0.030 145); - --secondary: oklch(80% 0.040 280); - --secondary-background: oklch(22% 0.015 280); -} */ - +{{THEME_TOKENS}} diff --git a/templates/plugin-template/src/ui/pages.tsx b/templates/plugin-template/src/ui/pages.tsx index a0ec4386c..c3a6750d4 100644 --- a/templates/plugin-template/src/ui/pages.tsx +++ b/templates/plugin-template/src/ui/pages.tsx @@ -7,6 +7,7 @@ import { useUserSession } from "@vertesia/ui/session"; import { useUITranslation } from "@vertesia/ui/i18n"; import type { CreateAgentRunPayload } from "@vertesia/common"; import { ASSISTANT_INTERACTION } from "./constants"; +import { PageShell } from "./PageShell"; export function HomePage() { const { user } = useUserSession(); @@ -14,16 +15,18 @@ export function HomePage() { const navigate = useNavigate(); return ( -
-

{t('nav.welcome', { name: user?.name || user?.email })}

-

- {t('nav.templateDescription')} -

- -
+ navigate("/chat")}> + + {t("nav.tryAgentChat")} + + )} + > +
+ ); } @@ -38,7 +41,7 @@ export function ChatPage() { const payload: CreateAgentRunPayload = { interaction: ASSISTANT_INTERACTION, interactive: true, - data: { user_prompt: initialMessage || '' }, + data: { user_prompt: initialMessage || "" }, }; const result = await store.agents.start(payload); if (result) { @@ -48,14 +51,14 @@ export function ChatPage() { return undefined; }, [store, navigate]); - const handleReset = useCallback(() => navigate('/chat'), [navigate]); + const handleReset = useCallback(() => navigate("/chat"), [navigate]); return (
Not found
, - } + path: "*", + Component: () =>
Not found
, + hideFromNav: true, + }, ]; diff --git a/templates/plugin-template/template.config.json b/templates/plugin-template/template.config.json index e8a877c3a..6bb38b8a3 100644 --- a/templates/plugin-template/template.config.json +++ b/templates/plugin-template/template.config.json @@ -31,6 +31,40 @@ "value": true } ] + }, + { + "type": "select", + "name": "THEME_TOKENS", + "message": "Theme palette", + "initial": 0, + "choices": [ + { + "title": "Slate-blue (light default, dark mode supported)", + "description": "Calm professional palette. Good neutral starting point.", + "value": ":root {\n --primary: oklch(58% 0.180 265);\n --primary-background: oklch(97% 0.020 265);\n --secondary: oklch(40% 0.060 280);\n --secondary-background: oklch(96% 0.010 280);\n --ring: oklch(58% 0.180 265 / 0.45);\n}\n\n.dark {\n --primary: oklch(72% 0.170 265);\n --primary-background: oklch(22% 0.040 265);\n --secondary: oklch(78% 0.040 280);\n --secondary-background: oklch(20% 0.020 280);\n --ring: oklch(72% 0.170 265 / 0.55);\n}" + }, + { + "title": "Indigo + violet (richer brand feel)", + "description": "Saturated primary for marketing-friendly plugins.", + "value": ":root {\n --primary: oklch(56% 0.190 290);\n --primary-background: oklch(97% 0.020 290);\n --secondary: oklch(45% 0.110 320);\n --secondary-background: oklch(96% 0.020 320);\n --ring: oklch(56% 0.190 290 / 0.45);\n}\n\n.dark {\n --primary: oklch(74% 0.170 290);\n --primary-background: oklch(23% 0.040 290);\n --secondary: oklch(78% 0.110 320);\n --secondary-background: oklch(22% 0.030 320);\n --ring: oklch(74% 0.170 290 / 0.55);\n}" + }, + { + "title": "Cyan accent — futuristic dark default", + "description": "Neon cyan primary tuned for a dark-first plugin. Pair with class=\"dark\" on .", + "value": ":root {\n --primary: oklch(70% 0.180 200);\n --primary-background: oklch(96% 0.020 200);\n --secondary: oklch(45% 0.060 240);\n --secondary-background: oklch(96% 0.010 240);\n --ring: oklch(70% 0.180 200 / 0.45);\n}\n\n.dark {\n --primary: oklch(80% 0.180 200);\n --primary-background: oklch(22% 0.040 230);\n --secondary: oklch(80% 0.060 240);\n --secondary-background: oklch(20% 0.020 240);\n --ring: oklch(80% 0.180 200 / 0.65);\n}" + }, + { + "title": "Lime accent — friendly light default", + "description": "Energetic green primary for tools-feeling plugins.", + "value": ":root {\n --primary: oklch(62% 0.180 145);\n --primary-background: oklch(97% 0.030 145);\n --secondary: oklch(45% 0.060 220);\n --secondary-background: oklch(96% 0.010 220);\n --ring: oklch(62% 0.180 145 / 0.45);\n}\n\n.dark {\n --primary: oklch(78% 0.190 145);\n --primary-background: oklch(22% 0.040 145);\n --secondary: oklch(80% 0.060 220);\n --secondary-background: oklch(22% 0.020 220);\n --ring: oklch(78% 0.190 145 / 0.55);\n}" + } + ] + }, + { + "type": "text", + "name": "ASSISTANT_INTERACTION", + "message": "Assistant interaction id (the agent the persistent assistant will run)", + "initial": "sys:GeneralAgent" } ], "derived": { @@ -51,7 +85,9 @@ "vite.config.ts", "index.html", "src/tool-server/config.ts", + "src/ui/constants.ts", "src/ui/env.ts", + "src/ui/index.css", "src/ui/plugin.tsx", ".env.app.template" ], From 4b40605a15bac1c99863b8b9bd31931b6a18369d Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Sun, 3 May 2026 19:27:00 +0900 Subject: [PATCH 38/75] feat: update theme customization and remove theme token selection from config --- templates/plugin-template/src/ui/index.css | 26 +++++++++++++---- .../plugin-template/template.config.json | 29 ------------------- 2 files changed, 21 insertions(+), 34 deletions(-) diff --git a/templates/plugin-template/src/ui/index.css b/templates/plugin-template/src/ui/index.css index 20833e0a1..faff5f74e 100644 --- a/templates/plugin-template/src/ui/index.css +++ b/templates/plugin-template/src/ui/index.css @@ -9,11 +9,27 @@ @source "../../node_modules/@vertesia/tools-admin-ui/src"; /* - * Theme Customization — generated from the `THEME_TOKENS` prompt during scaffolding. + * Theme Customization * - * Adjust to match your brand. Values use oklch(lightness chroma hue). - * See https://oklch.com for a color picker. To switch the app to dark by - * default, also add class="dark" on in index.html. + * Slate-blue default palette — calm, neutral, light + dark. + * Edit the oklch values below to brand your plugin (https://oklch.com). + * For a dark-first plugin, also set class="dark" on in index.html. + * + * Looking for ready-made palettes? See "Theme presets" in the template README. */ -{{THEME_TOKENS}} +:root { + --primary: oklch(58% 0.180 265); + --primary-background: oklch(97% 0.020 265); + --secondary: oklch(40% 0.060 280); + --secondary-background: oklch(96% 0.010 280); + --ring: oklch(58% 0.180 265 / 0.45); +} + +.dark { + --primary: oklch(72% 0.170 265); + --primary-background: oklch(22% 0.040 265); + --secondary: oklch(78% 0.040 280); + --secondary-background: oklch(20% 0.020 280); + --ring: oklch(72% 0.170 265 / 0.55); +} diff --git a/templates/plugin-template/template.config.json b/templates/plugin-template/template.config.json index 6bb38b8a3..20b6d1094 100644 --- a/templates/plugin-template/template.config.json +++ b/templates/plugin-template/template.config.json @@ -32,34 +32,6 @@ } ] }, - { - "type": "select", - "name": "THEME_TOKENS", - "message": "Theme palette", - "initial": 0, - "choices": [ - { - "title": "Slate-blue (light default, dark mode supported)", - "description": "Calm professional palette. Good neutral starting point.", - "value": ":root {\n --primary: oklch(58% 0.180 265);\n --primary-background: oklch(97% 0.020 265);\n --secondary: oklch(40% 0.060 280);\n --secondary-background: oklch(96% 0.010 280);\n --ring: oklch(58% 0.180 265 / 0.45);\n}\n\n.dark {\n --primary: oklch(72% 0.170 265);\n --primary-background: oklch(22% 0.040 265);\n --secondary: oklch(78% 0.040 280);\n --secondary-background: oklch(20% 0.020 280);\n --ring: oklch(72% 0.170 265 / 0.55);\n}" - }, - { - "title": "Indigo + violet (richer brand feel)", - "description": "Saturated primary for marketing-friendly plugins.", - "value": ":root {\n --primary: oklch(56% 0.190 290);\n --primary-background: oklch(97% 0.020 290);\n --secondary: oklch(45% 0.110 320);\n --secondary-background: oklch(96% 0.020 320);\n --ring: oklch(56% 0.190 290 / 0.45);\n}\n\n.dark {\n --primary: oklch(74% 0.170 290);\n --primary-background: oklch(23% 0.040 290);\n --secondary: oklch(78% 0.110 320);\n --secondary-background: oklch(22% 0.030 320);\n --ring: oklch(74% 0.170 290 / 0.55);\n}" - }, - { - "title": "Cyan accent — futuristic dark default", - "description": "Neon cyan primary tuned for a dark-first plugin. Pair with class=\"dark\" on .", - "value": ":root {\n --primary: oklch(70% 0.180 200);\n --primary-background: oklch(96% 0.020 200);\n --secondary: oklch(45% 0.060 240);\n --secondary-background: oklch(96% 0.010 240);\n --ring: oklch(70% 0.180 200 / 0.45);\n}\n\n.dark {\n --primary: oklch(80% 0.180 200);\n --primary-background: oklch(22% 0.040 230);\n --secondary: oklch(80% 0.060 240);\n --secondary-background: oklch(20% 0.020 240);\n --ring: oklch(80% 0.180 200 / 0.65);\n}" - }, - { - "title": "Lime accent — friendly light default", - "description": "Energetic green primary for tools-feeling plugins.", - "value": ":root {\n --primary: oklch(62% 0.180 145);\n --primary-background: oklch(97% 0.030 145);\n --secondary: oklch(45% 0.060 220);\n --secondary-background: oklch(96% 0.010 220);\n --ring: oklch(62% 0.180 145 / 0.45);\n}\n\n.dark {\n --primary: oklch(78% 0.190 145);\n --primary-background: oklch(22% 0.040 145);\n --secondary: oklch(80% 0.060 220);\n --secondary-background: oklch(22% 0.020 220);\n --ring: oklch(78% 0.190 145 / 0.55);\n}" - } - ] - }, { "type": "text", "name": "ASSISTANT_INTERACTION", @@ -87,7 +59,6 @@ "src/tool-server/config.ts", "src/ui/constants.ts", "src/ui/env.ts", - "src/ui/index.css", "src/ui/plugin.tsx", ".env.app.template" ], From 061dca0e0e5843631e03e61227dfee426dec1a77 Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Sun, 3 May 2026 19:34:41 +0900 Subject: [PATCH 39/75] docs(plugin-template): note TS18003 gotcha when deleting all widgets If a developer following the 'remove example resources you don't need' guidance deletes every .tsx widget, `tsc --build` fails because the widgets project has zero inputs. Document the workaround (drop the widgets reference from tsconfig.json's references) and the trade-off (ESLint project service won't typecheck future widget files). --- templates/plugin-template/CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/templates/plugin-template/CLAUDE.md b/templates/plugin-template/CLAUDE.md index ade34d18a..db4675ac4 100644 --- a/templates/plugin-template/CLAUDE.md +++ b/templates/plugin-template/CLAUDE.md @@ -68,6 +68,7 @@ pnpm start # Preview production build (build:server + vite previ - **Input onChange API**: `@vertesia/ui` Input passes value directly (`onChange={setValue}`), not a React event — Textarea uses standard events - **listConversations limitations**: Does not return the `input` field — only `topic` is available for labeling conversations; fall back to date/time - **getRunDetails** for full data: Use `client.store.workflows.getRunDetails(runId, workflowId)` when you need `input` or history +- **No widgets, TS18003**: `tsconfig.widgets.json` includes `src/tool-server/skills/**/*.tsx`. If you delete every example skill that ships a widget, `tsc --build` fails because the project has no inputs. Either keep at least one `.tsx` widget (recommended), or remove the `tsconfig.widgets.json` entry from `tsconfig.json`'s `references` array. Add it back when you ship your first widget. Note the trade-off: dropping the reference means ESLint's project service won't typecheck future widget files. ## Skills From abda22854c8372d533f2ac630abb2229ddeb3d2c Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Sun, 3 May 2026 21:30:32 +0900 Subject: [PATCH 40/75] feat(plugin-template): conditional widgets typecheck via scripts/typecheck.mjs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces `tsc --build --noEmit` in `prebuild` with a small Node script that runs `tsc -p --noEmit` per project, only including `tsconfig.widgets.json` when at least one .tsx file exists under src/tool-server/skills/. Plugins that ship without widgets no longer hit TS18003. Plugins that add widgets later get them typechecked automatically — no tsconfig.json edits required. Updates eslint.config.js to apply node globals to scripts/, and drops the now-obsolete CLAUDE.md gotcha note. --- templates/plugin-template/CLAUDE.md | 2 +- templates/plugin-template/eslint.config.js | 8 +++ templates/plugin-template/package.json | 2 +- .../plugin-template/scripts/typecheck.mjs | 55 +++++++++++++++++++ 4 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 templates/plugin-template/scripts/typecheck.mjs diff --git a/templates/plugin-template/CLAUDE.md b/templates/plugin-template/CLAUDE.md index db4675ac4..3a322c676 100644 --- a/templates/plugin-template/CLAUDE.md +++ b/templates/plugin-template/CLAUDE.md @@ -68,7 +68,7 @@ pnpm start # Preview production build (build:server + vite previ - **Input onChange API**: `@vertesia/ui` Input passes value directly (`onChange={setValue}`), not a React event — Textarea uses standard events - **listConversations limitations**: Does not return the `input` field — only `topic` is available for labeling conversations; fall back to date/time - **getRunDetails** for full data: Use `client.store.workflows.getRunDetails(runId, workflowId)` when you need `input` or history -- **No widgets, TS18003**: `tsconfig.widgets.json` includes `src/tool-server/skills/**/*.tsx`. If you delete every example skill that ships a widget, `tsc --build` fails because the project has no inputs. Either keep at least one `.tsx` widget (recommended), or remove the `tsconfig.widgets.json` entry from `tsconfig.json`'s `references` array. Add it back when you ship your first widget. Note the trade-off: dropping the reference means ESLint's project service won't typecheck future widget files. +- **No widgets is fine**: `prebuild` runs `node scripts/typecheck.mjs`, which auto-detects whether `src/tool-server/skills/**/*.tsx` exists and only includes `tsconfig.widgets.json` when it does. Delete every example skill safely; add a widget later and the script picks it up automatically. No need to edit `tsconfig.json`. ## Skills diff --git a/templates/plugin-template/eslint.config.js b/templates/plugin-template/eslint.config.js index ae5c519fb..b22e1326c 100644 --- a/templates/plugin-template/eslint.config.js +++ b/templates/plugin-template/eslint.config.js @@ -6,6 +6,14 @@ import tseslint from 'typescript-eslint' export default [ { ignores: ['dist', 'node_modules', 'lib', '*.config.js', '*.config.widgets.js', 'lib'] }, + { + files: ['scripts/**/*.{js,mjs}'], + languageOptions: { + ecmaVersion: 2024, + sourceType: 'module', + globals: globals.node, + }, + }, { files: ['**/*.{ts,tsx}'], languageOptions: { diff --git a/templates/plugin-template/package.json b/templates/plugin-template/package.json index dac2b692b..d638bb590 100644 --- a/templates/plugin-template/package.json +++ b/templates/plugin-template/package.json @@ -12,7 +12,7 @@ "license": "Apache-2.0", "scripts": { "dev": "vite dev --mode app", - "prebuild": "pnpm run lint && tsc --build --noEmit", + "prebuild": "pnpm run lint && node scripts/typecheck.mjs", "build": "pnpm run build:server && pnpm run build:ui", "build:server": "rollup -c", "build:ui": "pnpm run build:ui:app && pnpm run build:ui:lib", diff --git a/templates/plugin-template/scripts/typecheck.mjs b/templates/plugin-template/scripts/typecheck.mjs new file mode 100644 index 000000000..41cf8efe2 --- /dev/null +++ b/templates/plugin-template/scripts/typecheck.mjs @@ -0,0 +1,55 @@ +#!/usr/bin/env node +/** + * Run tsc --noEmit on each project that actually has inputs. + * + * The widgets project (tsconfig.widgets.json) only globs `*.tsx` files under + * `src/tool-server/skills/`. If a plugin removes all example skills (or never + * ships a widget), `tsc --build` errors with TS18003 ("No inputs were found"). + * + * This script auto-detects widgets and only runs the widgets typecheck when + * at least one .tsx file is present, so the prebuild stays green for both + * new plugins and plugins that keep widgets. + */ +import { execSync } from "node:child_process"; +import { existsSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; + +const cwd = process.cwd(); + +function hasFileMatching(dir, predicate) { + if (!existsSync(dir)) return false; + const entries = readdirSync(dir); + for (const name of entries) { + const full = join(dir, name); + const st = statSync(full); + if (st.isDirectory()) { + if (hasFileMatching(full, predicate)) return true; + } else if (predicate(name, full)) { + return true; + } + } + return false; +} + +const projects = [ + "tsconfig.ui.json", + "tsconfig.tool-server.json", + "tsconfig.node.json", +]; + +const widgetsRoot = join(cwd, "src", "tool-server", "skills"); +const hasWidgets = hasFileMatching(widgetsRoot, (name) => name.endsWith(".tsx")); +if (hasWidgets) { + projects.push("tsconfig.widgets.json"); +} else { + console.log("[typecheck] no widget .tsx found under src/tool-server/skills/, skipping tsconfig.widgets.json"); +} + +for (const project of projects) { + if (!existsSync(join(cwd, project))) { + console.log(`[typecheck] ${project} not found, skipping`); + continue; + } + console.log(`[typecheck] tsc -p ${project} --noEmit`); + execSync(`tsc -p ${project} --noEmit`, { stdio: "inherit" }); +} From 32a1aaa8c528dbac2871ce4502ebe4f90721aeea Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Sun, 3 May 2026 22:10:33 +0900 Subject: [PATCH 41/75] Add appgen dev auth support --- packages/create-plugin/src/index.ts | 15 ++++++++++--- packages/ui/src/env/index.ts | 11 ++++++++++ .../ui/src/session/UserSessionProvider.tsx | 22 +++++++++++++++++++ packages/ui/src/session/auth/composable.ts | 9 ++++++-- templates/plugin-template/src/ui/env.ts | 1 + templates/plugin-template/vite.config.ts | 7 +++--- 6 files changed, 57 insertions(+), 8 deletions(-) diff --git a/packages/create-plugin/src/index.ts b/packages/create-plugin/src/index.ts index 7db3f87f7..45a4e4007 100644 --- a/packages/create-plugin/src/index.ts +++ b/packages/create-plugin/src/index.ts @@ -39,6 +39,7 @@ async function main() { .option('-y, --yes', 'Non-interactive mode: use defaults for all prompts', false) .option('--dev', 'Use workspace dependencies (development mode)', false) .option('--local-templates ', 'Use local template directory instead of fetching from GitHub') + .option('--skip-install', 'Skip dependency installation after copying and configuring the template', false) .option('--package-manager ', 'Package manager to use: pnpm or npm (overrides template default and interactive selection)') .addHelpText('after', ` Available Templates: @@ -49,8 +50,8 @@ Documentation: ${config.docsUrl} .parse(); let projectName = program.args[0]; - const opts = program.opts<{ branch?: string; template?: string; yes: boolean; dev: boolean; localTemplates?: string; packageManager?: string }>(); - const { branch, template, yes: nonInteractive, dev, localTemplates, packageManager: packageManagerOverride } = opts; + const opts = program.opts<{ branch?: string; template?: string; yes: boolean; dev: boolean; localTemplates?: string; skipInstall: boolean; packageManager?: string }>(); + const { branch, template, yes: nonInteractive, dev, localTemplates, skipInstall, packageManager: packageManagerOverride } = opts; // Prompt for project name if not provided as CLI argument if (!projectName) { @@ -137,6 +138,14 @@ Documentation: ${config.docsUrl} } // Step 10: Install dependencies + if (skipInstall) { + console.log(chalk.yellow('⚠️ Skipping dependency installation (--skip-install).\n')); + console.log(chalk.gray('You can install dependencies manually when needed:\n')); + console.log(chalk.gray(` cd ${projectName}`)); + console.log(chalk.gray(` ${packageManager} install\n`)); + skipDependencyInstall = true; + } + if (!skipDependencyInstall) { await installDependencies(projectName, packageManager); } @@ -175,4 +184,4 @@ function showSuccess(projectName: string, packageManager: string, templateName: main().catch((error) => { console.error(chalk.red(`\n❌ Fatal error: ${error.message}\n`)); process.exit(1); -}); \ No newline at end of file +}); diff --git a/packages/ui/src/env/index.ts b/packages/ui/src/env/index.ts index 976a24187..8079c9dba 100644 --- a/packages/ui/src/env/index.ts +++ b/packages/ui/src/env/index.ts @@ -26,6 +26,13 @@ export interface EnvProps { region?: string, datadogRum?: boolean, datadogLogs?: boolean, + /** + * Development-only Vertesia auth token. + * + * This is intended for sandbox/dev previews where the host process already + * has a short-lived Vertesia token. Production apps must not set this. + */ + devAuthToken?: string, logger?: { info: (msg: string, ...args: any) => void, warn: (msg: string, ...args: any) => void, @@ -117,6 +124,10 @@ export class VertesiaEnvironment implements Readonly { return this._props?.datadogLogs ?? false; } + get devAuthToken() { + return this._props?.devAuthToken; + } + get logger() { return this._props?.logger ?? console } diff --git a/packages/ui/src/session/UserSessionProvider.tsx b/packages/ui/src/session/UserSessionProvider.tsx index 84b53e8b5..ef801ed81 100644 --- a/packages/ui/src/session/UserSessionProvider.tsx +++ b/packages/ui/src/session/UserSessionProvider.tsx @@ -57,6 +57,28 @@ export function UserSessionProvider({ children }: UserSessionProviderProps) { }, }); + if (Env.isLocalDev && Env.devAuthToken) { + session.setSession = setSession; + getComposableToken(selectedAccount, selectedProject, Env.devAuthToken) + .then((res) => { + session.login(res.rawToken).then(() => setSession(session.clone())); + }) + .catch((err) => { + console.error("Failed to initialize dev auth token", err); + Env.logger.error("Failed to initialize dev auth token", { + vertesia: { + account_id: selectedAccount, + project_id: selectedProject, + error: err, + }, + }); + session.isLoading = false; + session.authError = err instanceof Error ? err : new Error(String(err)); + setSession(session.clone()); + }); + return; + } + if (token && state) { session.setSession = setSession; const validationError = verifyState(state); diff --git a/packages/ui/src/session/auth/composable.ts b/packages/ui/src/session/auth/composable.ts index 59c278541..4069a9898 100644 --- a/packages/ui/src/session/auth/composable.ts +++ b/packages/ui/src/session/auth/composable.ts @@ -253,17 +253,22 @@ export async function getComposableToken(accountId?: string, projectId?: string, const selectedAccount = accountId ?? localStorage.getItem(LastSelectedAccountId_KEY) ?? undefined const selectedProject = projectId ?? localStorage.getItem(LastSelectedProjectId_KEY + '-' + selectedAccount) ?? undefined + const devAuthToken = Env.isLocalDev ? Env.devAuthToken : undefined; //token is still valid for more than 5 minutes if (!forceRefresh && AUTH_TOKEN_RAW && AUTH_TOKEN && AUTH_TOKEN.exp > (Date.now() / 1000 + 300)) { return { rawToken: AUTH_TOKEN_RAW, token: AUTH_TOKEN, error: false }; } + if (devAuthToken) { + AUTH_TOKEN_RAW = devAuthToken; + } + //token is close to expire, refresh it - if (!useInternalAuth && getFirebaseAuth().currentUser) { + if (!devAuthToken && !useInternalAuth && getFirebaseAuth().currentUser) { //we have a firebase user, get the token from there AUTH_TOKEN_RAW = await fetchComposableTokenFromFirebaseToken(selectedAccount, selectedProject); - } else if (initToken || AUTH_TOKEN_RAW) { + } else if (!devAuthToken && (initToken || AUTH_TOKEN_RAW)) { // we have a token already and no firebase user, refresh it AUTH_TOKEN_RAW = await fetchComposableToken(() => Promise.resolve(initToken ?? AUTH_TOKEN_RAW), selectedAccount, selectedProject); } diff --git a/templates/plugin-template/src/ui/env.ts b/templates/plugin-template/src/ui/env.ts index 41b52a108..cb05ce291 100644 --- a/templates/plugin-template/src/ui/env.ts +++ b/templates/plugin-template/src/ui/env.ts @@ -10,6 +10,7 @@ Env.init({ isLocalDev: true, isDocker: true, type: "development", + devAuthToken: import.meta.env.DEV ? import.meta.env.VITE_VERTESIA_AUTH_TOKEN : undefined, endpoints: { studio: "https://api.us1.vertesia.io", zeno: "https://api.us1.vertesia.io", diff --git a/templates/plugin-template/vite.config.ts b/templates/plugin-template/vite.config.ts index ec212188d..bcc049f72 100644 --- a/templates/plugin-template/vite.config.ts +++ b/templates/plugin-template/vite.config.ts @@ -89,8 +89,9 @@ function defineLibConfig({ command }: ConfigEnv): UserConfig { * @returns */ function defineAppConfig({ command }: ConfigEnv): UserConfig { - // Vercel dev proxies to the framework dev server over HTTP — HTTPS would break that. - const useHttps = !process.env.VERCEL; + // DEV_MODE is used by appgen/sandbox previews. Vercel also proxies to the + // framework dev server over HTTP, so both modes disable HTTPS. + const useHttps = process.env.DEV_MODE !== '1' && process.env.VERCEL !== '1'; const base = command === 'build' ? '/app/' : '/'; return { @@ -98,7 +99,7 @@ function defineAppConfig({ command }: ConfigEnv): UserConfig { plugins: [ tailwindcss(), react(), - // HTTPS is required for Firebase auth but must be disabled under vercel dev + // HTTPS is required for Firebase auth but must be disabled under appgen/Vercel dev ...(useHttps ? [basicSsl()] : []), // serve lib/plugin.js content in dev mode serveStatic([ From 72107b3e5a7f4934084c1a5930557bd9c77dd55d Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Sun, 3 May 2026 23:09:27 +0900 Subject: [PATCH 42/75] Stabilize appgen dev preview --- templates/plugin-template/vite.config.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/templates/plugin-template/vite.config.ts b/templates/plugin-template/vite.config.ts index bcc049f72..e548fb2af 100644 --- a/templates/plugin-template/vite.config.ts +++ b/templates/plugin-template/vite.config.ts @@ -114,6 +114,9 @@ function defineAppConfig({ command }: ConfigEnv): UserConfig { build: { outDir: 'dist/app', // App build goes to dist/app/ }, + optimizeDeps: process.env.DEV_MODE === '1' + ? { noDiscovery: true, include: [] } + : undefined, // for authentication with Firebase server: { proxy: { From a1f222b7beffdfa719d1af80ddeb5faf28b405af Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Mon, 4 May 2026 02:43:54 +0900 Subject: [PATCH 43/75] fix: avoid regex slash trimming in app endpoints --- packages/common/src/apps.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/common/src/apps.ts b/packages/common/src/apps.ts index e315cd70d..ff59c967f 100644 --- a/packages/common/src/apps.ts +++ b/packages/common/src/apps.ts @@ -548,10 +548,18 @@ export function substituteEndpoints(url: string, endpoints?: Endpoints): string return url.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, key: string) => { const value = (endpoints as Record)[key]; if (typeof value !== 'string' || !value) return match; - return value.replace(/\/+$/, ''); + return trimTrailingSlashes(value); }); } +function trimTrailingSlashes(value: string): string { + let end = value.length; + while (end > 0 && value[end - 1] === '/') { + end--; + } + return end === value.length ? value : value.slice(0, end); +} + /** * Resolves the effective endpoint for an app given an optional environment name * and deployment-time URL variables. From 56f6eddbba259a1f1d6185fe984089a9cdd781d7 Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Mon, 4 May 2026 22:18:21 +0900 Subject: [PATCH 44/75] feat(plugin-template): enhance plugin structure with new dependencies and dev session provider --- pnpm-lock.yaml | 6 ++ templates/plugin-template/package.json | 4 +- .../src/ui/DevSessionProvider.tsx | 42 +++++++++ .../plugin-template/src/ui/PluginSidebar.tsx | 22 ++++- templates/plugin-template/src/ui/env.ts | 16 ++-- templates/plugin-template/src/ui/main.tsx | 86 +++++++++++++++---- templates/plugin-template/vite.config.ts | 11 ++- 7 files changed, 160 insertions(+), 27 deletions(-) create mode 100644 templates/plugin-template/src/ui/DevSessionProvider.tsx diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fabbb4f93..e2a246f6b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1044,9 +1044,15 @@ importers: hono: specifier: ^4.12.14 version: 4.12.14 + html-parse-stringify: + specifier: ^3.0.1 + version: 3.0.1 lucide-react: specifier: ^0.562.0 version: 0.562.0(react@19.2.3) + use-sync-external-store: + specifier: ^1.6.0 + version: 1.6.0(react@19.2.3) devDependencies: '@eslint/js': specifier: ^9.0.0 diff --git a/templates/plugin-template/package.json b/templates/plugin-template/package.json index d638bb590..57ed80c42 100644 --- a/templates/plugin-template/package.json +++ b/templates/plugin-template/package.json @@ -83,6 +83,8 @@ "@vertesia/ui": "workspace:*", "dotenv": "^17.2.3", "hono": "^4.10.3", - "lucide-react": "^0.562.0" + "html-parse-stringify": "^3.0.1", + "lucide-react": "^0.562.0", + "use-sync-external-store": "^1.6.0" } } diff --git a/templates/plugin-template/src/ui/DevSessionProvider.tsx b/templates/plugin-template/src/ui/DevSessionProvider.tsx new file mode 100644 index 000000000..e0bf4abe8 --- /dev/null +++ b/templates/plugin-template/src/ui/DevSessionProvider.tsx @@ -0,0 +1,42 @@ +import type { AuthTokenPayload } from '@vertesia/common' +import { LastSelectedAccountId_KEY, LastSelectedProjectId_KEY, UserSession, UserSessionContext } from '@vertesia/ui/session' +import { useMemo } from 'react' +import type { ReactNode } from 'react' + +function decodeJwtPayload(token: string): AuthTokenPayload { + const [, payload] = token.split('.'); + if (!payload) { + throw new Error('Invalid Vertesia auth token'); + } + const padded = payload.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(payload.length / 4) * 4, '='); + return JSON.parse(atob(padded)) as AuthTokenPayload; +} + +interface DevSessionProviderProps { + children: ReactNode; + token: string; +} + +export function DevSessionProvider({ children, token }: DevSessionProviderProps) { + const session = useMemo(() => { + const next = new UserSession(); + next.isLoading = false; + + try { + next.authToken = decodeJwtPayload(token); + next.client.withAuthCallback(() => Promise.resolve(`Bearer ${token}`)); + + localStorage.setItem(LastSelectedAccountId_KEY, next.authToken.account.id); + localStorage.setItem( + `${LastSelectedProjectId_KEY}-${next.authToken.account.id}`, + next.authToken.project?.id ?? '', + ); + } catch (error: unknown) { + next.authError = error instanceof Error ? error : new Error(String(error)); + } + + return next.clone(); + }, [token]); + + return {children}; +} diff --git a/templates/plugin-template/src/ui/PluginSidebar.tsx b/templates/plugin-template/src/ui/PluginSidebar.tsx index dfcac3479..e052dc41c 100644 --- a/templates/plugin-template/src/ui/PluginSidebar.tsx +++ b/templates/plugin-template/src/ui/PluginSidebar.tsx @@ -5,7 +5,7 @@ import { SidebarSection, useSidebarToggle } from "@vertesia/ui/layout"; import { useLocation, useRouterBasePath } from "@vertesia/ui/router"; import { useUserSession } from "@vertesia/ui/session"; import { MessageSquare } from "lucide-react"; -import type { AgentRunResponse, WorkflowRun } from "@vertesia/common"; +import type { WorkflowRun } from "@vertesia/common"; import { AppSidebarItem } from "./AppSidebarItem"; import { ASSISTANT_INTERACTION } from "./constants"; import { routes } from "./routes"; @@ -34,7 +34,23 @@ function groupNavRoutes(items: PluginRoute[]): Array<{ title?: string; items: Pl return order.map(key => ({ title: key || undefined, items: groups.get(key)! })); } -function toWorkflowRun(run: AgentRunResponse): WorkflowRun { +interface AgentRunListItem { + id: string; + run_kind?: string; + workflow_id?: string; + status: WorkflowRun["status"]; + started_at?: Date | string | null; + completed_at?: Date | string | null; + topic?: string; + title?: string; + data?: Record; + interaction_name?: string; + visibility?: WorkflowRun["visibility"]; + activity_state?: WorkflowRun["activity_state"]; + interactive?: boolean; +} + +function toWorkflowRun(run: AgentRunListItem): WorkflowRun { const isAgentRun = run.run_kind === "agent"; return { run_id: run.id, @@ -119,7 +135,7 @@ export function PluginSidebar({ showConversations = true }: PluginSidebarProps) limit: 20, sort: "started_at", order: "desc", - }).then(response => setConversations(response.items.map(toWorkflowRun))); + }).then(response => setConversations(response.items.map(run => toWorkflowRun(run as AgentRunListItem)))); }, [client, showConversations]); const groupedConversations = useMemo(() => groupByDate(conversations, t), [conversations, t]); diff --git a/templates/plugin-template/src/ui/env.ts b/templates/plugin-template/src/ui/env.ts index cb05ce291..20820e32e 100644 --- a/templates/plugin-template/src/ui/env.ts +++ b/templates/plugin-template/src/ui/env.ts @@ -1,19 +1,23 @@ import { Env } from "@vertesia/ui/env"; const CONFIG__PLUGIN_TITLE = "Ui Plugin Template"; +const isDev = import.meta.env.DEV; +const devApiEndpoint = "/vertesia-api"; document.title = CONFIG__PLUGIN_TITLE; -Env.init({ +const envConfig: Parameters[0] & { devAuthToken?: string } = { name: CONFIG__PLUGIN_TITLE, version: "1.0.0", isLocalDev: true, isDocker: true, type: "development", - devAuthToken: import.meta.env.DEV ? import.meta.env.VITE_VERTESIA_AUTH_TOKEN : undefined, + devAuthToken: isDev ? import.meta.env.VITE_VERTESIA_AUTH_TOKEN : undefined, endpoints: { - studio: "https://api.us1.vertesia.io", - zeno: "https://api.us1.vertesia.io", - sts: "https://sts.us1.vertesia.io", + studio: import.meta.env.VITE_VERTESIA_STUDIO_URL ?? (isDev ? devApiEndpoint : "https://api.us1.vertesia.io"), + zeno: import.meta.env.VITE_VERTESIA_ZENO_URL ?? (isDev ? devApiEndpoint : "https://api.us1.vertesia.io"), + sts: import.meta.env.VITE_VERTESIA_STS_URL ?? (isDev ? "https://sts.dev1.vertesia.io" : "https://sts.us1.vertesia.io"), } -}); +}; + +Env.init(envConfig); diff --git a/templates/plugin-template/src/ui/main.tsx b/templates/plugin-template/src/ui/main.tsx index 4ebd9ff9d..f817f1260 100644 --- a/templates/plugin-template/src/ui/main.tsx +++ b/templates/plugin-template/src/ui/main.tsx @@ -1,6 +1,9 @@ +import { ThemeProvider, ToastProvider } from '@vertesia/ui/core' +import { TypeRegistryProvider, UserPermissionProvider } from '@vertesia/ui/features' import { I18nProvider } from '@vertesia/ui/i18n' -import { StandaloneApp, VertesiaShell } from '@vertesia/ui/shell' +import { SigninScreen, StandaloneApp, VertesiaShell } from '@vertesia/ui/shell' import { StrictMode } from 'react' +import type { ReactNode } from 'react' import { createRoot } from 'react-dom/client' import './i18n'; // register plugin-specific translations import './index.css' @@ -9,6 +12,7 @@ import { AdminApp } from '@vertesia/tools-admin-ui' import { RouterProvider, type Route } from '@vertesia/ui/router' import { App } from './app' import { setUsePluginAssets } from './assets' +import { DevSessionProvider } from './DevSessionProvider' import "./env" import { OrgGate } from './OrgGate' import { PluginAccessDenied } from './PluginAccessDenied' @@ -17,28 +21,78 @@ import { PluginLayout } from './PluginLayout' setUsePluginAssets(false); const appName = import.meta.env.VITE_APP_NAME; +const devAuthToken = import.meta.env.DEV ? import.meta.env.VITE_VERTESIA_AUTH_TOKEN : undefined; + +function resolveApiBaseUrl() { + const configured = import.meta.env.VITE_APP_API_BASE_URL ?? import.meta.env.VITE_APP_API_BASE; + if (configured) return configured.replace(/\/+$/, ''); + + const parts = window.location.pathname.split('/').filter(Boolean); + if (parts[0] === 'live' && parts[1]) { + return `/live/${parts[1]}/api`; + } + if (parts[0] === 'tenants' && parts[2] === 'apps' && parts[4] === 'versions' && parts[5]) { + return `/${parts.slice(0, 6).join('/')}/api`; + } + return '/api'; +} + +const apiBaseUrl = resolveApiBaseUrl(); + +function AppRoute({ enforceInstallation }: { enforceInstallation: boolean }) { + const content = ( + + + + ); + + return enforceInstallation ? ( + + {content} + + ) : content; +} const routes: Route[] = [ - { path: "*", Component: () => }, - { - path: "app/*", Component: () => ( - - - - - - ) - }, + { path: "*", Component: () => }, + { path: "app/*", Component: () => }, ] +function DevShell({ children, token }: { children: ReactNode; token: string }) { + return ( + + + + + + + {children} + + + + + + ); +} + +const shell = devAuthToken ? ( + + + + + +) : ( + + + + + +); + createRoot(document.getElementById('root')!).render( - - - - - + {shell} , ) diff --git a/templates/plugin-template/vite.config.ts b/templates/plugin-template/vite.config.ts index e548fb2af..b6553ce42 100644 --- a/templates/plugin-template/vite.config.ts +++ b/templates/plugin-template/vite.config.ts @@ -93,6 +93,9 @@ function defineAppConfig({ command }: ConfigEnv): UserConfig { // framework dev server over HTTP, so both modes disable HTTPS. const useHttps = process.env.DEV_MODE !== '1' && process.env.VERCEL !== '1'; const base = command === 'build' ? '/app/' : '/'; + const devApiTarget = process.env.VERTESIA_STUDIO_PROXY_TARGET + ?? process.env.VITE_VERTESIA_STUDIO_PROXY_TARGET + ?? 'https://api.dev1.vertesia.io'; return { base, // Dev serves the admin UI at /; Vercel serves built app assets from /app/. @@ -115,11 +118,17 @@ function defineAppConfig({ command }: ConfigEnv): UserConfig { outDir: 'dist/app', // App build goes to dist/app/ }, optimizeDeps: process.env.DEV_MODE === '1' - ? { noDiscovery: true, include: [] } + ? { include: ['html-parse-stringify', 'use-sync-external-store/shim'] } : undefined, // for authentication with Firebase server: { proxy: { + '/vertesia-api': { + target: devApiTarget, + changeOrigin: true, + secure: true, + rewrite: (path) => path.replace(/^\/vertesia-api/, ''), + }, '/__/auth': { target: 'https://dengenlabs.firebaseapp.com', changeOrigin: true, From 0af9197b9c95444322f1fe7deeaf543ba6ddc5ae Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Tue, 5 May 2026 17:39:23 +0900 Subject: [PATCH 45/75] feat: add support for fetching composable token from Vertesia JWT and caching functionality --- packages/ui/src/session/auth/composable.ts | 14 ++++++++++++++ packages/ui/src/shell/login/TerminalLogin.tsx | 7 +++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/session/auth/composable.ts b/packages/ui/src/session/auth/composable.ts index 4069a9898..e5545b22c 100644 --- a/packages/ui/src/session/auth/composable.ts +++ b/packages/ui/src/session/auth/composable.ts @@ -249,6 +249,20 @@ export async function fetchComposableTokenFromFirebaseToken(accountId?: string, return fetchComposableToken(getFirebaseAuthToken, accountId, projectId, ttl); } +/** + * Mint a scoped Vertesia token from an existing Vertesia JWT. STS accepts STS-issued + * tokens on /token/issue, so this works for sessions established via Central Auth where + * the browser has no Firebase user. + */ +export async function fetchComposableTokenFromVertesiaToken(vertesiaToken: string, accountId?: string, projectId?: string, ttl?: number) { + return fetchComposableToken(() => Promise.resolve(vertesiaToken), accountId, projectId, ttl); +} + +/** Returns the cached Vertesia raw JWT, if any. Does not refresh. */ +export function getCurrentVertesiaToken(): string | undefined { + return AUTH_TOKEN_RAW; +} + export async function getComposableToken(accountId?: string, projectId?: string, initToken?: string, forceRefresh = false, useInternalAuth = false): Promise { const selectedAccount = accountId ?? localStorage.getItem(LastSelectedAccountId_KEY) ?? undefined diff --git a/packages/ui/src/shell/login/TerminalLogin.tsx b/packages/ui/src/shell/login/TerminalLogin.tsx index 3fbf6db80..a69fbd19f 100644 --- a/packages/ui/src/shell/login/TerminalLogin.tsx +++ b/packages/ui/src/shell/login/TerminalLogin.tsx @@ -2,7 +2,7 @@ import { AccountRef, ProjectRef } from '@vertesia/common' import { Button, Center, ErrorBox, Input, SelectBox, Spinner, useFetch, useToast } from '@vertesia/ui/core' import { Env } from "@vertesia/ui/env" import { useLocation } from "@vertesia/ui/router" -import { fetchComposableTokenFromFirebaseToken, useUserSession } from '@vertesia/ui/session' +import { fetchComposableTokenFromFirebaseToken, fetchComposableTokenFromVertesiaToken, getCurrentVertesiaToken, useUserSession } from '@vertesia/ui/session' import { useState } from 'react' import { useUITranslation } from '../../i18n/index.js' @@ -117,7 +117,10 @@ export function TerminalLogin() { // expire in 1 day let payload: LoginResult | undefined try { - const token = await fetchComposableTokenFromFirebaseToken(data.account, data.project, 24 * 3600) + const vertesiaToken = getCurrentVertesiaToken() + const token = vertesiaToken + ? await fetchComposableTokenFromVertesiaToken(vertesiaToken, data.account, data.project, 24 * 3600) + : await fetchComposableTokenFromFirebaseToken(data.account, data.project, 24 * 3600) if (token) { payload = { ...data, From 63fb38fbdc1e2491a8b6b8ccaafe0ec4c045313b Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Tue, 5 May 2026 18:17:27 +0900 Subject: [PATCH 46/75] fix(cli): exit cleanly on Ctrl-C in legacy auth refresh prompt enquirer 2.x resolves the input prompt with { result: '' } on Ctrl-C rather than rejecting. The previous code only ran cleanup() on success or signal-abort paths, so the loopback HTTP server stayed open and the process hung. Treat empty answer as cancel: cleanup + exit 130. Also runs cleanup in the prompt's catch path for the rejection variant. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/src/profiles/server/index.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/cli/src/profiles/server/index.ts b/packages/cli/src/profiles/server/index.ts index 62fc4fd48..ea2cdfcb7 100644 --- a/packages/cli/src/profiles/server/index.ts +++ b/packages/cli/src/profiles/server/index.ts @@ -187,9 +187,17 @@ export async function startConfigSession( console.error("Invalid token"); process.exit(1); } + } else { + // Empty answer — enquirer 2.x resolves with `result: ''` on Ctrl-C + // rather than rejecting. Without this branch the HTTP server stays + // open and the process hangs. + cleanup(); + console.log("\nAuthentication cancelled."); + process.exit(130); } } catch (err: unknown) { // This could be thrown if the prompt is interrupted + cleanup(); if (signal?.aborted) { return; } From 3853e3002a2355275c41b6eb2d676f0452a22e6a Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Wed, 6 May 2026 11:46:22 +0900 Subject: [PATCH 47/75] feat: implement app version management with new API endpoints and data structures --- packages/client/src/AppsApi.ts | 27 ++++++ packages/common/src/apps.ts | 93 +++++++++++++++++++ .../plugin-template/src/ui/PluginSidebar.tsx | 7 +- templates/plugin-template/src/ui/api-base.ts | 16 ++++ templates/plugin-template/src/ui/main.tsx | 23 ++--- templates/plugin-template/vite.config.ts | 1 + .../ui-plugin-template/.env.local.template | 1 - templates/ui-plugin-template/README.md | 2 +- .../src/DevSessionProvider.tsx | 42 +++++++++ templates/ui-plugin-template/src/env.ts | 12 ++- templates/ui-plugin-template/src/main.tsx | 45 +++++++-- .../ui-plugin-template/template.config.json | 8 +- templates/ui-plugin-template/vite.config.ts | 26 +++++- 13 files changed, 264 insertions(+), 39 deletions(-) create mode 100644 templates/plugin-template/src/ui/api-base.ts delete mode 100644 templates/ui-plugin-template/.env.local.template create mode 100644 templates/ui-plugin-template/src/DevSessionProvider.tsx diff --git a/packages/client/src/AppsApi.ts b/packages/client/src/AppsApi.ts index e3a75833e..bd6725c5b 100644 --- a/packages/client/src/AppsApi.ts +++ b/packages/client/src/AppsApi.ts @@ -9,10 +9,14 @@ import type { AppManifestData, AppPackage, AppToolCollection, + AppVersionListQuery, + AppVersionRecord, + ActivateAppVersionResponse, CountResult, ProjectRef, RequireAtLeastOne, UpdateAppInstallationToolAllowlistPayload, + UpsertAppVersionRequest, ValidateUrlRequest, ValidateUrlResponse, } from "@vertesia/common"; @@ -35,6 +39,29 @@ export default class AppsApi extends ApiTopic { return this.put(`/${id}`, { payload: manifest }); } + listVersions(query?: AppVersionListQuery): Promise { + return this.get('/versions', { + query: { + ...(query?.app_id && { app_id: query.app_id }), + ...(query?.kind && { kind: query.kind }), + ...(query?.include_expired !== undefined && { include_expired: query.include_expired }), + ...(query?.limit !== undefined && { limit: query.limit }), + }, + }); + } + + upsertVersion(payload: UpsertAppVersionRequest): Promise { + return this.post('/versions', { payload }); + } + + getVersion(recordId: string): Promise { + return this.get(`/versions/${recordId}`); + } + + activateVersion(recordId: string): Promise { + return this.post(`/versions/${recordId}/activate`); + } + /** * Get the list if tools provided by the given app. * @param appId diff --git a/packages/common/src/apps.ts b/packages/common/src/apps.ts index ff59c967f..b5893634d 100644 --- a/packages/common/src/apps.ts +++ b/packages/common/src/apps.ts @@ -394,6 +394,99 @@ export interface RemoteActivityDefinition { export type AppCapabilities = 'ui' | 'tools' | 'interactions' | 'types' | 'processes' | 'templates' | 'dashboards'; export type AppAvailableIn = 'app_portal' | 'composite_app'; + +export type AppVersionKind = 'preview' | 'published'; +export type AppVersionState = 'ready' | 'failed' | 'expired'; +export type AppVersionTarget = 'static' | 'service'; + +export interface AppVersionStorage { + tenant_id?: string; + app_prefix?: string; + source_archive?: string; + build_prefix?: string; + manifest_path?: string; + service_archive?: string; + live_metadata_path?: string; +} + +export interface AppVersionUrls { + live_url?: string; + app_url?: string; + plugin_url?: string; + package_url?: string; + internal_preview_url?: string; +} + +export interface AppVersionRecord { + id: string; + account: string; + project: string; + app?: string; + app_id: string; + app_name: string; + version_id: string; + kind: AppVersionKind; + state: AppVersionState; + active?: boolean; + target?: AppVersionTarget; + agent_run_id?: string; + sandbox_id?: string; + title?: string; + description?: string; + storage?: AppVersionStorage; + urls?: AppVersionUrls; + manifest?: Record; + files?: string[]; + file_count?: number; + source_file_count?: number; + screenshot_artifact?: string; + checks?: string[]; + created_by?: string; + created_at: string; + updated_at: string; + published_at?: string; + checked_at?: string; + expires_at?: string; +} + +export interface UpsertAppVersionRequest { + app?: string; + app_id: string; + app_name?: string; + version_id: string; + kind: AppVersionKind; + state?: AppVersionState; + active?: boolean; + target?: AppVersionTarget; + agent_run_id?: string; + sandbox_id?: string; + title?: string; + description?: string; + storage?: AppVersionStorage; + urls?: AppVersionUrls; + manifest?: Record; + files?: string[]; + file_count?: number; + source_file_count?: number; + screenshot_artifact?: string; + checks?: string[]; + published_at?: string; + checked_at?: string; + expires_at?: string; +} + +export interface AppVersionListQuery { + app_id?: string; + kind?: AppVersionKind; + include_expired?: boolean; + limit?: number; +} + +export interface ActivateAppVersionResponse { + version: AppVersionRecord; + app?: AppManifest; +} + export interface AppManifestData { /** * The name of the app, used as the id in the system. diff --git a/templates/plugin-template/src/ui/PluginSidebar.tsx b/templates/plugin-template/src/ui/PluginSidebar.tsx index e052dc41c..b88640d6c 100644 --- a/templates/plugin-template/src/ui/PluginSidebar.tsx +++ b/templates/plugin-template/src/ui/PluginSidebar.tsx @@ -135,7 +135,12 @@ export function PluginSidebar({ showConversations = true }: PluginSidebarProps) limit: 20, sort: "started_at", order: "desc", - }).then(response => setConversations(response.items.map(run => toWorkflowRun(run as AgentRunListItem)))); + }) + .then(response => { + const items = Array.isArray(response.items) ? response.items : []; + setConversations(items.map(run => toWorkflowRun(run as AgentRunListItem))); + }) + .catch(() => setConversations([])); }, [client, showConversations]); const groupedConversations = useMemo(() => groupByDate(conversations, t), [conversations, t]); diff --git a/templates/plugin-template/src/ui/api-base.ts b/templates/plugin-template/src/ui/api-base.ts new file mode 100644 index 000000000..8bfa6a826 --- /dev/null +++ b/templates/plugin-template/src/ui/api-base.ts @@ -0,0 +1,16 @@ +export function resolveAppApiBaseUrl() { + const configured = import.meta.env.VITE_APP_API_BASE_URL ?? import.meta.env.VITE_APP_API_BASE; + if (configured) return configured.replace(/\/+$/, ''); + + const parts = window.location.pathname.split('/').filter(Boolean); + if (parts[0] === 'live' && parts[1]) { + return `/live/${parts[1]}/api`; + } + if (parts[0] === 'tenants' && parts[2] === 'live' && parts[3]) { + return `/${parts.slice(0, 4).join('/')}/api`; + } + if (parts[0] === 'tenants' && parts[2] === 'apps' && parts[4] === 'versions' && parts[5]) { + return `/${parts.slice(0, 6).join('/')}/api`; + } + return '/api'; +} diff --git a/templates/plugin-template/src/ui/main.tsx b/templates/plugin-template/src/ui/main.tsx index f817f1260..f6f60ef31 100644 --- a/templates/plugin-template/src/ui/main.tsx +++ b/templates/plugin-template/src/ui/main.tsx @@ -10,6 +10,7 @@ import './index.css' // initialize dev environment import { AdminApp } from '@vertesia/tools-admin-ui' import { RouterProvider, type Route } from '@vertesia/ui/router' +import { resolveAppApiBaseUrl } from './api-base' import { App } from './app' import { setUsePluginAssets } from './assets' import { DevSessionProvider } from './DevSessionProvider' @@ -22,22 +23,7 @@ setUsePluginAssets(false); const appName = import.meta.env.VITE_APP_NAME; const devAuthToken = import.meta.env.DEV ? import.meta.env.VITE_VERTESIA_AUTH_TOKEN : undefined; - -function resolveApiBaseUrl() { - const configured = import.meta.env.VITE_APP_API_BASE_URL ?? import.meta.env.VITE_APP_API_BASE; - if (configured) return configured.replace(/\/+$/, ''); - - const parts = window.location.pathname.split('/').filter(Boolean); - if (parts[0] === 'live' && parts[1]) { - return `/live/${parts[1]}/api`; - } - if (parts[0] === 'tenants' && parts[2] === 'apps' && parts[4] === 'versions' && parts[5]) { - return `/${parts.slice(0, 6).join('/')}/api`; - } - return '/api'; -} - -const apiBaseUrl = resolveApiBaseUrl(); +const apiBaseUrl = resolveAppApiBaseUrl(); function AppRoute({ enforceInstallation }: { enforceInstallation: boolean }) { const content = ( @@ -54,8 +40,11 @@ function AppRoute({ enforceInstallation }: { enforceInstallation: boolean }) { } const routes: Route[] = [ - { path: "*", Component: () => }, + { path: "tenants/:tenantId/live/:agentRunId/app/*", Component: () => }, + { path: "live/:agentRunId/app/*", Component: () => }, + { path: "tenants/:tenantId/apps/:appId/versions/:versionId/app/*", Component: () => }, { path: "app/*", Component: () => }, + { path: "*", Component: () => }, ] function DevShell({ children, token }: { children: ReactNode; token: string }) { diff --git a/templates/plugin-template/vite.config.ts b/templates/plugin-template/vite.config.ts index b6553ce42..6deb372d9 100644 --- a/templates/plugin-template/vite.config.ts +++ b/templates/plugin-template/vite.config.ts @@ -122,6 +122,7 @@ function defineAppConfig({ command }: ConfigEnv): UserConfig { : undefined, // for authentication with Firebase server: { + hmr: process.env.APPGEN_DISABLE_HMR === '1' ? false : undefined, proxy: { '/vertesia-api': { target: devApiTarget, diff --git a/templates/ui-plugin-template/.env.local.template b/templates/ui-plugin-template/.env.local.template deleted file mode 100644 index 8ed666a60..000000000 --- a/templates/ui-plugin-template/.env.local.template +++ /dev/null @@ -1 +0,0 @@ -VITE_APP_NAME={{APP_ID}} diff --git a/templates/ui-plugin-template/README.md b/templates/ui-plugin-template/README.md index 909a3f894..09e4ee44f 100644 --- a/templates/ui-plugin-template/README.md +++ b/templates/ui-plugin-template/README.md @@ -34,7 +34,7 @@ src/ pnpm install ``` -Next, set the app Id in the `VITE_APP_NAME` variable in the `.env.local` file. +For local appgen previews and publishing, the app name is injected by the app workspace tooling. For manual local development outside appgen, set `VITE_APP_NAME` in your shell environment before running Vite. ### Development diff --git a/templates/ui-plugin-template/src/DevSessionProvider.tsx b/templates/ui-plugin-template/src/DevSessionProvider.tsx new file mode 100644 index 000000000..e818ff13a --- /dev/null +++ b/templates/ui-plugin-template/src/DevSessionProvider.tsx @@ -0,0 +1,42 @@ +import type { AuthTokenPayload } from '@vertesia/common' +import { LastSelectedAccountId_KEY, LastSelectedProjectId_KEY, UserSession, UserSessionContext } from '@vertesia/ui/session' +import { useMemo } from 'react' +import type { ReactNode } from 'react' + +function decodeJwtPayload(token: string): AuthTokenPayload { + const [, payload] = token.split('.') + if (!payload) { + throw new Error('Invalid Vertesia auth token') + } + const padded = payload.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(payload.length / 4) * 4, '=') + return JSON.parse(atob(padded)) as AuthTokenPayload +} + +interface DevSessionProviderProps { + children: ReactNode + token: string +} + +export function DevSessionProvider({ children, token }: DevSessionProviderProps) { + const session = useMemo(() => { + const next = new UserSession() + next.isLoading = false + + try { + next.authToken = decodeJwtPayload(token) + next.client.withAuthCallback(() => Promise.resolve(`Bearer ${token}`)) + + localStorage.setItem(LastSelectedAccountId_KEY, next.authToken.account.id) + localStorage.setItem( + `${LastSelectedProjectId_KEY}-${next.authToken.account.id}`, + next.authToken.project?.id ?? '', + ) + } catch (error: unknown) { + next.authError = error instanceof Error ? error : new Error(String(error)) + } + + return next.clone() + }, [token]) + + return {children} +} diff --git a/templates/ui-plugin-template/src/env.ts b/templates/ui-plugin-template/src/env.ts index da1d16498..6e92e1852 100644 --- a/templates/ui-plugin-template/src/env.ts +++ b/templates/ui-plugin-template/src/env.ts @@ -1,6 +1,11 @@ import { Env } from "@vertesia/ui/env"; const CONFIG__PLUGIN_TITLE = "Ui Plugin Template"; +const isDev = import.meta.env.DEV; +const devApiEndpoint = "/vertesia-api"; +const devStsEndpoint = "https://sts.dev1.vertesia.io"; + +document.title = CONFIG__PLUGIN_TITLE; Env.init({ name: CONFIG__PLUGIN_TITLE, @@ -8,9 +13,10 @@ Env.init({ isLocalDev: true, isDocker: true, type: "development", + devAuthToken: isDev ? import.meta.env.VITE_VERTESIA_AUTH_TOKEN : undefined, endpoints: { - studio: "https://api.us1.vertesia.io", - zeno: "https://api.us1.vertesia.io", - sts: "https://sts.us1.vertesia.io", + studio: import.meta.env.VITE_VERTESIA_STUDIO_URL ?? (isDev ? devApiEndpoint : "https://api.us1.vertesia.io"), + zeno: import.meta.env.VITE_VERTESIA_ZENO_URL ?? (isDev ? devApiEndpoint : "https://api.us1.vertesia.io"), + sts: import.meta.env.VITE_VERTESIA_STS_URL ?? (isDev ? devStsEndpoint : "https://sts.us1.vertesia.io"), } }); diff --git a/templates/ui-plugin-template/src/main.tsx b/templates/ui-plugin-template/src/main.tsx index fb08c9e88..140b06391 100644 --- a/templates/ui-plugin-template/src/main.tsx +++ b/templates/ui-plugin-template/src/main.tsx @@ -1,24 +1,57 @@ +import { ThemeProvider, ToastProvider } from '@vertesia/ui/core' +import { TypeRegistryProvider, UserPermissionProvider } from '@vertesia/ui/features' import { I18nProvider } from '@vertesia/ui/i18n' -import { StandaloneApp, VertesiaShell } from '@vertesia/ui/shell' +import { SigninScreen, StandaloneApp, VertesiaShell } from '@vertesia/ui/shell' import { StrictMode } from 'react' +import type { ReactNode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' // initialize dev environment import { RouterProvider } from '@vertesia/ui/router' import { App } from './app' +import { DevSessionProvider } from './DevSessionProvider' import "./env" import { setUsePluginAssets } from './assets' setUsePluginAssets(false); +const appName = import.meta.env.VITE_APP_NAME +const devAuthToken = import.meta.env.DEV ? import.meta.env.VITE_VERTESIA_AUTH_TOKEN : undefined +const appContent = + +function DevShell({ children, token }: { children: ReactNode; token: string }) { + return ( + + + + + + + {children} + + + + + + ) +} + +const shell = devAuthToken ? ( + + {appContent} + +) : ( + + + {appContent} + + +) + createRoot(document.getElementById('root')!).render( - - {/* <---- define VITE_APP_NAME en var in .env.local */} - - - + {shell} , ) diff --git a/templates/ui-plugin-template/template.config.json b/templates/ui-plugin-template/template.config.json index 9ff0fdaf3..6fc380392 100644 --- a/templates/ui-plugin-template/template.config.json +++ b/templates/ui-plugin-template/template.config.json @@ -47,13 +47,9 @@ "vite.config.ts", "index.html", "src/env.ts", - "src/plugin.tsx", - ".env.local.template" + "src/plugin.tsx" ], - "renameFiles": { - ".env.local.template": ".env.local" - }, "removeAfterInstall": [ "template.config.json" ] -} \ No newline at end of file +} diff --git a/templates/ui-plugin-template/vite.config.ts b/templates/ui-plugin-template/vite.config.ts index 3c36a4642..2285e0bd2 100644 --- a/templates/ui-plugin-template/vite.config.ts +++ b/templates/ui-plugin-template/vite.config.ts @@ -46,7 +46,7 @@ export default defineConfig((env) => { if (env.mode === 'lib') { return defineLibConfig(env); } else { - return defineAppConfig(); + return defineAppConfig(env); } }) @@ -87,14 +87,22 @@ function defineLibConfig({ command }: ConfigEnv): UserConfig { * or to build a standalone application. * @returns */ -function defineAppConfig(): UserConfig { +function defineAppConfig({ command }: ConfigEnv): UserConfig { + // DEV_MODE is used by appgen/sandbox previews. Vercel also proxies to the + // framework dev server over HTTP, so both modes disable HTTPS. + const useHttps = process.env.DEV_MODE !== '1' && process.env.VERCEL !== '1'; + const base = command === 'build' ? '/app/' : '/'; + const devApiTarget = process.env.VERTESIA_STUDIO_PROXY_TARGET + ?? process.env.VITE_VERTESIA_STUDIO_PROXY_TARGET + ?? 'https://api.dev1.vertesia.io'; return { + base, plugins: [ tailwindcss(), react(), - // we need to use https for the firebase authentication to work - basicSsl(), + // HTTPS is required for Firebase auth but must be disabled under appgen/Vercel dev. + ...(useHttps ? [basicSsl()] : []), // serve lib/plugin.js content in dev mode serveStatic([ { @@ -103,9 +111,19 @@ function defineAppConfig(): UserConfig { }, ]), ], + optimizeDeps: process.env.DEV_MODE === '1' + ? { include: ['html-parse-stringify', 'use-sync-external-store/shim'] } + : undefined, // for authentication with Firebase server: { + hmr: process.env.APPGEN_DISABLE_HMR === '1' ? false : undefined, proxy: { + '/vertesia-api': { + target: devApiTarget, + changeOrigin: true, + secure: true, + rewrite: (path) => path.replace(/^\/vertesia-api/, ''), + }, '/__/auth': { target: 'https://dengenlabs.firebaseapp.com', changeOrigin: true, From cbccd01fda666c7f933a59a8f8a4c19adab32a03 Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Wed, 6 May 2026 14:32:20 +0900 Subject: [PATCH 48/75] feat(plugin-template): update routing for plugin UI and admin tools in main.tsx --- templates/plugin-template/CLAUDE.md | 2 +- templates/plugin-template/src/ui/main.tsx | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/templates/plugin-template/CLAUDE.md b/templates/plugin-template/CLAUDE.md index 3a322c676..4b7246b7c 100644 --- a/templates/plugin-template/CLAUDE.md +++ b/templates/plugin-template/CLAUDE.md @@ -38,7 +38,7 @@ pnpm start # Preview production build (build:server + vite previ | `src/tool-server/config.ts` | Registers all collections — add new resources here | | `src/tool-server/settings.ts` | Plugin settings JSON Schema | | `src/ui/plugin.tsx` | Library entry for Vertesia host app | -| `src/ui/main.tsx` | Standalone dev entry (VertesiaShell + AdminApp) | +| `src/ui/main.tsx` | Standalone dev entry (VertesiaShell). Routes: `/` and `/app/*` → plugin UI; `/tools/*` → AdminApp (tools / skills / interactions / types / templates / dashboards) | | `src/ui/routes.tsx` | Route definitions (NestedRouterProvider) | | `src/ui/index.css` | Tailwind CSS 4 entry with shared styles import | diff --git a/templates/plugin-template/src/ui/main.tsx b/templates/plugin-template/src/ui/main.tsx index f6f60ef31..19378e7c3 100644 --- a/templates/plugin-template/src/ui/main.tsx +++ b/templates/plugin-template/src/ui/main.tsx @@ -43,8 +43,11 @@ const routes: Route[] = [ { path: "tenants/:tenantId/live/:agentRunId/app/*", Component: () => }, { path: "live/:agentRunId/app/*", Component: () => }, { path: "tenants/:tenantId/apps/:appId/versions/:versionId/app/*", Component: () => }, + // Tool-server admin (tools / skills / interactions / types / templates / dashboards) at /tools/*. + { path: "tools/*", Component: () => }, + // Plugin UI is the default — `/`, `/app/*`, and any other path. { path: "app/*", Component: () => }, - { path: "*", Component: () => }, + { path: "*", Component: () => }, ] function DevShell({ children, token }: { children: ReactNode; token: string }) { From b587ea08ca1d5548e9f11ec0109a3b5e42a819eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Vachette?= <5880528+michaelva@users.noreply.github.com> Date: Thu, 7 May 2026 15:15:46 +0900 Subject: [PATCH 49/75] improve skills --- .../plugin-template/.claude/settings.json | 52 +++ .../skills/vertesia-demo-content/SKILL.md | 105 +++++ .../scripts/generate_markdown_seed.py | 207 +++++++++ .../skills/vertesia-dsl-workflow/SKILL.md | 212 +++++++++ .../skills/vertesia-gap-assessment/SKILL.md | 187 ++++++++ .../references/sources-and-checks.md | 198 ++++++++ .../skills/vertesia-plugin/REFERENCE.md | 37 ++ .../.claude/skills/vertesia-plugin/SKILL.md | 45 +- .../.claude/skills/vertesia-ui/SKILL.md | 111 +++++ .../references/generic-table-pattern.md | 437 ++++++++++++++++++ 10 files changed, 1581 insertions(+), 10 deletions(-) create mode 100644 templates/plugin-template/.claude/settings.json create mode 100644 templates/plugin-template/.claude/skills/vertesia-demo-content/SKILL.md create mode 100755 templates/plugin-template/.claude/skills/vertesia-demo-content/scripts/generate_markdown_seed.py create mode 100644 templates/plugin-template/.claude/skills/vertesia-dsl-workflow/SKILL.md create mode 100644 templates/plugin-template/.claude/skills/vertesia-gap-assessment/SKILL.md create mode 100644 templates/plugin-template/.claude/skills/vertesia-gap-assessment/references/sources-and-checks.md create mode 100644 templates/plugin-template/.claude/skills/vertesia-ui/references/generic-table-pattern.md diff --git a/templates/plugin-template/.claude/settings.json b/templates/plugin-template/.claude/settings.json new file mode 100644 index 000000000..2991c5845 --- /dev/null +++ b/templates/plugin-template/.claude/settings.json @@ -0,0 +1,52 @@ +{ + "permissions": { + "allow": [ + "Read", + "Glob", + "Grep", + "Bash(git status:*)", + "Bash(git log:*)", + "Bash(git diff:*)", + "Bash(git branch:*)", + "Bash(git show:*)", + "Bash(git ls-remote:*)", + "Bash(git rev-parse:*)", + "Bash(git remote:*)", + "Bash(git tag:*)", + "Bash(git stash list:*)", + "Bash(git blame:*)", + "Bash(git fetch:*)", + "Bash(gh pr list:*)", + "Bash(gh pr view:*)", + "Bash(gh pr checks:*)", + "Bash(gh pr diff:*)", + "Bash(gh issue list:*)", + "Bash(gh issue view:*)", + "Bash(gh repo view:*)", + "Bash(gh run list:*)", + "Bash(gh run view:*)", + "Bash(gh run watch:*)", + "Bash(gh workflow list:*)", + "Bash(gh workflow view:*)", + "Bash(gh api:*)", + "Bash(gcloud * list:*)", + "Bash(gcloud * describe:*)", + "Bash(gcloud * get:*)", + "Bash(cd *)", + "Bash(ls:*)", + "Bash(pwd:*)", + "Bash(pnpm build:*)", + "Bash(pnpm run build:*)", + "Bash(pnpm turbo build:*)", + "Bash(turbo build:*)", + "Bash(pnpm tsc:*)", + "Bash(pnpm lint:*)", + "Bash(pnpm test:*)", + "Bash(pnpm vitest:*)", + "Bash(pnpm vitest run:*)", + "Bash(npx vitest run:*)", + "WebSearch", + "WebFetch" + ] + } +} \ No newline at end of file diff --git a/templates/plugin-template/.claude/skills/vertesia-demo-content/SKILL.md b/templates/plugin-template/.claude/skills/vertesia-demo-content/SKILL.md new file mode 100644 index 000000000..594bbfa05 --- /dev/null +++ b/templates/plugin-template/.claude/skills/vertesia-demo-content/SKILL.md @@ -0,0 +1,105 @@ +--- +name: vertesia-demo-content +description: Generate realistic reusable demo or seed content for Vertesia applications and upload it through the Vertesia CLI, API, or browser session. Use when building Vertesia custom apps, plugins, prototypes, repository workflows, AI extraction workflows, search/filter pages, or demos where the app should use real store objects instead of hardcoded mock arrays; includes checking the active CLI profile and asking before mutating a Vertesia project. +--- + +# Vertesia Demo Content + +Use this skill when a Vertesia app needs real content in the store so upload, search, filters, metadata extraction, workflows, and detail pages can be exercised end to end. + +## Core Rule + +Prefer realistic generated content uploaded to Vertesia over hardcoded UI mock data. Static arrays are acceptable only as temporary empty states, fallback examples, or tests that intentionally avoid network/project mutation. + +## Workflow + +1. Inspect the active target before mutation: + - Run `vertesia profiles show`. + - Identify the active/default profile, account, project, environment, and API base URL. + - Do not print full API tokens in the final answer. + - Ask the user to confirm the target project before uploading, deleting, or updating content. + +2. Confirm the target content type: + - Prefer the actual type id from Vertesia’s type catalog or the app/tool-server package. + - For app-contributed in-code types, do not assume the local type name is the runtime create id. + - Distinguish: + - query/search type name, for example `clm_contract` + - app in-code create id, for example `app::clm:ClmContract` + - If uncertain, inspect available types with CLI, project app-type APIs, and app endpoints before uploading. + +3. Generate useful files: + - Use Markdown for document-like demos unless the user requests DOCX, PDF, JSON, or another format. + - Include domain-specific details that make search, filters, extraction, summaries, and detail views meaningful. + - Include structured headings and bullet metadata so AI extraction has clear signals. + - Save generated files under `/tmp`, `/private/tmp`, or a user-approved fixture path. Do not commit generated seed data unless requested. + +4. Upload through the best available channel: + - Prefer `vertesia content post --type --mime text/markdown --name --path ` when the CLI profile is confirmed and the target type is a stored/system type or the CLI is known to accept the app-defined type. + - Use the browser/plugin session when upload must happen as the signed-in UI user. + - Use direct API when the CLI rejects an app-defined type but the project app-type APIs and runtime create path accept it. + +5. Wire the app to real objects: + - Replace static arrays with `store.objects.search`, `store.objects.list`, or collection search. + - Filter by full text plus metadata fields such as `properties.status`, `properties.owner`, `properties.category`, `properties.risk_level`, or app-specific fields. + - After upload, trigger a refetch or insert the created object into local state. + - For metadata extraction, read object text, run the relevant interaction, then update object properties. + +## App Type Rules + +When seeding app-defined content: + +- Use query/search names such as `clm_contract` for filters and object searches. +- Use the resolved app type code such as `app::clm:ClmContract` for object creation. +- If UI actions create child records, use the same explicit app type codes there too. +- If agent runs or workflow `executeInteraction` calls target app interactions, use the full app interaction id such as `app::clm:ExtractContractMetadata`. + +## CLI Limitation + +`vertesia content post` may validate only against `client.types.list()`, which can exclude app-defined in-code types. + +If the CLI says the type does not exist: + +1. verify the app package exposes the type +2. verify the project app-type APIs list and retrieve it +3. test direct object creation with the resolved app type code +4. use a repo-local uploader script or direct API if the project accepts the type but the CLI still rejects it + +Do not assume CLI rejection means the app type is missing from the project. + +## CLI Pattern + +Upload generated Markdown: + +```bash +vertesia content post /tmp/demo-seed/acme-doc.md \ + --type \ + --mime text/markdown \ + --name "Acme Demo Document" \ + --path /demo +``` + +List uploaded content: + +```bash +vertesia content list /demo +``` + +## Generator Script + +Use `scripts/generate_markdown_seed.py` to generate reusable Markdown seed data: + +```bash +python3 .claude/skills/vertesia-demo-content/scripts/generate_markdown_seed.py \ + --out /tmp/vertesia-demo \ + --domain generic \ + --count 3 +``` + +Supported built-in domains: + +- `generic`: reusable business documents for repository/search/extraction demos. +- `clm`: contract lifecycle documents with dates, values, renewal terms, obligations, and clauses. +- `support`: customer support cases with severity, owner, product, timeline, and resolution notes. +- `policy`: internal policy/procedure documents with owners, effective dates, controls, and exceptions. + +Use `--domain generic` unless the app domain is known. diff --git a/templates/plugin-template/.claude/skills/vertesia-demo-content/scripts/generate_markdown_seed.py b/templates/plugin-template/.claude/skills/vertesia-demo-content/scripts/generate_markdown_seed.py new file mode 100755 index 000000000..c215c30b3 --- /dev/null +++ b/templates/plugin-template/.claude/skills/vertesia-demo-content/scripts/generate_markdown_seed.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""Generate realistic Markdown seed documents for Vertesia demos.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + + +Sample = dict[str, str] + + +SAMPLES: dict[str, list[Sample]] = { + "generic": [ + { + "slug": "acme-business-review", + "title": "Acme Business Review", + "category": "Customer Review", + "owner": "Customer Success", + "status": "In Review", + "date": "2026-05-15", + "summary": "Quarterly account review covering adoption, risks, renewal outlook, and executive follow-ups.", + "details": "Acme expanded usage across three departments. The main open risk is delayed security questionnaire completion before renewal.", + "actions": "Schedule executive sponsor meeting; complete security questionnaire; prepare renewal pricing proposal.", + }, + { + "slug": "northstar-implementation-plan", + "title": "Northstar Implementation Plan", + "category": "Project Plan", + "owner": "Professional Services", + "status": "Active", + "date": "2026-06-01", + "summary": "Implementation plan for a phased rollout with data migration, training, acceptance criteria, and launch support.", + "details": "Phase one covers workspace setup and identity configuration. Phase two covers content migration and automation testing.", + "actions": "Confirm migration sample set; validate SSO; schedule administrator training.", + }, + { + "slug": "globex-risk-memo", + "title": "Globex Risk Review Memo", + "category": "Risk Memo", + "owner": "Operations", + "status": "Escalated", + "date": "2026-05-22", + "summary": "Operational risk memo documenting supplier delay, customer impact, mitigation plan, and approval needs.", + "details": "Supplier lead time increased from four weeks to nine weeks. Customer launch impact is likely unless substitute inventory is approved.", + "actions": "Approve substitute supplier; notify account team; update delivery forecast.", + }, + ], + "clm": [ + { + "slug": "acme-enterprise-saas-agreement", + "title": "Acme Enterprise SaaS Agreement", + "category": "Customer MSA", + "owner": "Sales Ops", + "status": "In Review", + "date": "2026-05-15", + "summary": "Enterprise subscription agreement with negotiated liability, data processing, uptime SLA, and renewal terms.", + "details": "Counterparty: Acme Corp. Value: $425,000 USD. Risk: High. Expiration: 2026-11-30. Renewal notice: 2026-08-01.", + "actions": "Finance review for contract value; legal review for liability carveouts; security review for data processing exhibit.", + }, + { + "slug": "northstar-vendor-services", + "title": "Northstar Vendor Services Agreement", + "category": "Vendor Agreement", + "owner": "Procurement", + "status": "Active", + "date": "2026-02-01", + "summary": "Managed services agreement with quarterly service reviews, payment milestones, and vendor performance obligations.", + "details": "Counterparty: Northstar Services. Value: $98,000 USD. Risk: Medium. Expiration: 2026-07-15. Renewal notice: 2026-04-16.", + "actions": "Track monthly service report; review SLA performance; prepare renewal recommendation.", + }, + { + "slug": "globex-channel-partner-addendum", + "title": "Globex Channel Partner Addendum", + "category": "Partner Amendment", + "owner": "Partner Sales", + "status": "Renewal Pending", + "date": "2026-04-01", + "summary": "Channel amendment with non-standard exclusivity, revenue-share commitments, and accelerated renewal decision timing.", + "details": "Counterparty: Globex. Value: $720,000 USD. Risk: Critical. Expiration: 2026-06-30. Renewal notice: 2026-05-15.", + "actions": "Executive review for exclusivity; legal review for territory language; renewal decision by notice date.", + }, + ], + "support": [ + { + "slug": "acme-login-incident", + "title": "Acme Login Incident", + "category": "Support Case", + "owner": "Support Engineering", + "status": "Open", + "date": "2026-05-03", + "summary": "Priority support case for intermittent login failures affecting Acme administrators.", + "details": "Severity: High. Product area: Authentication. Error rate increased after SSO certificate rotation.", + "actions": "Validate IdP metadata; rotate cached certificate; provide customer incident summary.", + }, + { + "slug": "northstar-export-request", + "title": "Northstar Export Performance Request", + "category": "Support Case", + "owner": "Customer Support", + "status": "Pending Customer", + "date": "2026-05-07", + "summary": "Customer reports slow exports for large document collections during month-end reporting.", + "details": "Severity: Medium. Product area: Reporting. Export size exceeds 50,000 rows.", + "actions": "Request sample export parameters; propose async export workflow; monitor next month-end run.", + }, + { + "slug": "globex-api-rate-limit", + "title": "Globex API Rate Limit Review", + "category": "Support Case", + "owner": "Developer Support", + "status": "Escalated", + "date": "2026-05-10", + "summary": "Integration is hitting API rate limits during nightly synchronization.", + "details": "Severity: High. Product area: API. Current client retries aggressively without backoff.", + "actions": "Share retry guidance; evaluate rate limit increase; review integration logs.", + }, + ], + "policy": [ + { + "slug": "data-retention-policy", + "title": "Data Retention Policy", + "category": "Policy", + "owner": "Compliance", + "status": "Approved", + "date": "2026-01-01", + "summary": "Policy defining retention periods, archival requirements, deletion approvals, and exception handling.", + "details": "Customer records are retained for seven years unless a shorter contractual retention period applies.", + "actions": "Review exceptions quarterly; confirm deletion audit trail; update retention schedule annually.", + }, + { + "slug": "vendor-onboarding-procedure", + "title": "Vendor Onboarding Procedure", + "category": "Procedure", + "owner": "Procurement", + "status": "Active", + "date": "2026-03-01", + "summary": "Procedure for onboarding vendors with security review, contract approval, tax setup, and performance tracking.", + "details": "Critical vendors require security approval before contract execution and annual reassessment.", + "actions": "Collect W-9; complete security questionnaire; create vendor performance record.", + }, + { + "slug": "incident-response-standard", + "title": "Incident Response Standard", + "category": "Standard", + "owner": "Security", + "status": "In Review", + "date": "2026-04-15", + "summary": "Operational standard for detecting, triaging, communicating, and resolving security incidents.", + "details": "Critical incidents require executive notification within one hour and customer notification review within twenty-four hours.", + "actions": "Validate escalation matrix; run tabletop exercise; update postmortem template.", + }, + ], +} + + +def render(sample: Sample, domain: str) -> str: + return f"""# {sample['title']} + +## Metadata + +- Domain: {domain} +- Category: {sample['category']} +- Owner: {sample['owner']} +- Status: {sample['status']} +- Record Date: {sample['date']} + +## Summary + +{sample['summary']} + +## Details + +{sample['details']} + +## Actions + +{sample['actions']} + +## Extraction Hints + +This document is suitable for testing Vertesia upload, full-text search, metadata filters, AI summarization, metadata extraction, workflow routing, and object detail views. +""" + + +def main() -> int: + parser = argparse.ArgumentParser(description="Generate Markdown seed documents for Vertesia demos.") + parser.add_argument("--out", required=True, help="Output directory.") + parser.add_argument("--domain", choices=sorted(SAMPLES), default="generic", help="Built-in sample domain.") + parser.add_argument("--count", type=int, default=3, help="Number of documents to generate.") + args = parser.parse_args() + + out = Path(args.out) + out.mkdir(parents=True, exist_ok=True) + + samples = SAMPLES[args.domain] + count = max(1, min(args.count, len(samples))) + for sample in samples[:count]: + path = out / f"{sample['slug']}.md" + path.write_text(render(sample, args.domain), encoding="utf-8") + print(path) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/templates/plugin-template/.claude/skills/vertesia-dsl-workflow/SKILL.md b/templates/plugin-template/.claude/skills/vertesia-dsl-workflow/SKILL.md new file mode 100644 index 000000000..99b1e2e6f --- /dev/null +++ b/templates/plugin-template/.claude/skills/vertesia-dsl-workflow/SKILL.md @@ -0,0 +1,212 @@ +--- +name: vertesia-dsl-workflow +description: Reference for DSL workflow definitions, remote activities, variable resolution, and conditions. Use when creating or debugging DSL workflows that call remote activities from plugins. +--- + +# DSL Workflows & Remote Activities + +DSL workflows are declarative step-by-step pipelines defined as JSON. They can call built-in activities and remote activities provided by plugins. + +## Key Source Files + +| File | Purpose | +|------|---------| +| `composableai/packages/workflow/src/dsl/dsl-workflow.ts` | DSL engine: variable init, step execution, remote activity calls | +| `composableai/packages/workflow/src/dsl/vars.ts` | `Vars` class: variable storage, resolution (`${varName}`), dotted path access | +| `composableai/packages/workflow/src/dsl/conditions.ts` | `matchCondition()`: operator-based condition matching | +| `composableai/packages/workflow/src/activities/getObjectFromStore.ts` | Built-in activity to load a ContentObject into workflow vars | +| `composableai/packages/tools-sdk/src/ActivityCollection.ts` | `ActivityCollection` class for registering plugin activities | + +## Workflow Definition Structure + +```typescript +await client.workflows.definitions.create({ + name: 'MyWorkflow', + description: 'Description', + debug_mode: true, + steps: [ + { name: 'activityName', type: 'activity', params: { ... } }, + // ... + ], + vars: {}, // initial workflow variables +}); +``` + +## Built-in Activities + +Common built-in activities available in all DSL workflows: + +| Activity | Purpose | Key Params | +|----------|---------|------------| +| `setDocumentStatus` | Set document status | `{ status: 'processing' \| 'completed' \| ... }` | +| `getObjectFromStore` | Load ContentObject into vars | `{ }` (uses objectId automatically) | +| `generateEmbeddings` | Compute embeddings | `{ type: 'text' \| 'properties' }` | + +### `getObjectFromStore` Pattern + +Use `output` to store the loaded object in a named variable, then access nested fields with dotted paths: + +```typescript +{ name: 'getObjectFromStore', type: 'activity', output: 'task' }, +// Now 'task' is in vars — access fields as 'task.properties.my_field' +``` + +## Remote Activities + +Plugin activities registered via `ActivityCollection` are invoked as DSL steps using the naming convention: + +``` +app::: +``` + +Example: `app:bpce-orchestrator:intake:categorize_task` + +App interactions used by `executeInteraction` follow a similar pattern: + +``` +app::: +``` + +Do not assume a bare interaction name like `ExtractContractMetadata` will resolve inside a workflow if the interaction is contributed by an app. + +### Variable Resolution for Remote Activities + +**CRITICAL**: Remote activity params are resolved in a **new `Vars` scope** (see `dsl-workflow.ts:422`). Workflow-level variables like `objectId` are NOT automatically available in remote activity params. + +You must use `import` to bring workflow variables into scope: + +```typescript +// WRONG - ${objectId} will be undefined +{ + name: 'app:my-plugin:collection:my_activity', + type: 'activity', + params: { task_id: '${objectId}' }, +} + +// CORRECT - import brings objectId into the new Vars scope +{ + name: 'app:my-plugin:collection:my_activity', + type: 'activity', + import: ['objectId'], + params: { task_id: '${objectId}' }, + output: 'result', +} +``` + +The `import` field tells the DSL engine to copy the listed variables from the workflow scope into the activity's `Vars` scope via `vars.createImportVars(activity.import)` at `dsl-workflow.ts:378`. + +### How `objectId` is Initialized + +At `dsl-workflow.ts:133`, the DSL engine initializes workflow vars with: +- `objectId` = first object ID from the trigger event (`objectIds[0]`) +- Plus any vars defined in the workflow definition's `vars` field + +## Conditions on Steps + +Use `condition` to skip steps based on workflow variable values. + +### Condition Syntax + +Conditions use operator objects from `conditions.ts`. The `matchCondition()` function iterates the keys of the condition value and looks them up in the `conditionFns` registry. + +**Available operators:** + +| Operator | Description | Example | +|----------|-------------|---------| +| `$eq` | Equals | `{ $eq: 'value' }` | +| `$ne` | Not equals | `{ $ne: 'value' }` | +| `$in` | In array | `{ $in: ['a', 'b'] }` | +| `$nin` | Not in array | `{ $nin: ['a', 'b'] }` | +| `$exists` | Field exists | `{ $exists: true }` | +| `$null` | Is null/undefined | `{ $null: true }` | +| `$gt`, `$gte`, `$lt`, `$lte` | Comparisons | `{ $gt: 50 }` | +| `$regexp` | Regex match | `{ $regexp: '^EPR-' }` | +| `$startsWith`, `$endsWith`, `$contains` | String ops | `{ $contains: 'text' }` | +| `$or` | OR conditions | `{ $or: [{ $eq: 'a' }, { $eq: 'b' }] }` | + +### CRITICAL: Always Use Operator Objects + +**NEVER pass plain values as conditions.** Plain strings/numbers cause `Unknown condition: 0` errors because `matchCondition()` iterates string characters as keys. + +```typescript +// WRONG - causes "Unknown condition: 0" error +condition: { 'task.properties.task_status': 'categorisation' } + +// CORRECT - use operator object +condition: { 'task.properties.task_status': { $eq: 'categorisation' } } +``` + +### Dotted Path Access in Conditions + +Conditions support dotted paths to access nested variables. The `vars.match()` method uses `getValue(path)` which resolves dotted paths like `task.properties.task_status`: + +```typescript +{ + name: 'app:my-plugin:collection:my_activity', + type: 'activity', + condition: { 'task.properties.field_name': { $eq: 'expected_value' } }, + import: ['objectId'], + params: { task_id: '${objectId}' }, +} +``` + +This requires a prior `getObjectFromStore` step with `output: 'task'` to populate the variable. + +## Complete Workflow Example + +A workflow that loads a task, conditionally processes it, computes embeddings, and sets status: + +```typescript +steps: [ + { name: 'setDocumentStatus', type: 'activity', params: { status: 'processing' } }, + { name: 'getObjectFromStore', type: 'activity', output: 'task' }, + { + name: 'app:my-plugin:intake:categorize_task', + type: 'activity', + condition: { 'task.properties.task_status': { $eq: 'categorisation' } }, + import: ['objectId'], + params: { task_id: '${objectId}' }, + output: 'categorize_result', + }, + { + name: 'app:my-plugin:intake:compute_priority', + type: 'activity', + condition: { 'task.properties.task_status': { $eq: 'categorisation' } }, + import: ['objectId'], + params: { task_id: '${objectId}' }, + output: 'priority_result', + }, + { name: 'generateEmbeddings', type: 'activity', params: { type: 'text' } }, + { name: 'generateEmbeddings', type: 'activity', params: { type: 'properties' } }, + { name: 'setDocumentStatus', type: 'activity', params: { status: 'completed' } }, +] +``` + +## Workflow Rules + +Workflow rules trigger workflows based on events. They match on event name and content type: + +```typescript +await client.workflows.rules.create({ + name: 'My Rule', + endpoint: `wf:${workflowDefinitionId}`, + match: { + '$and': [ + { 'event.name': 'create' }, + { 'event.data.type.name': 'my_content_type' }, + ] + }, +}); +``` + +When reusing a standard intake workflow such as `StandardDocumentIntake`, prefer staging custom enrichment on a later `update` event after the generic intake marks the object completed. This avoids two workflows racing on the same `create` event. + +## Common Pitfalls + +1. **`${objectId}` undefined in remote activity params**: Add `import: ['objectId']` to the step +2. **`Unknown condition: 0` error**: Condition value is a plain string, not an operator object — wrap with `{ $eq: ... }` +3. **Condition not matching nested fields**: Ensure a prior `getObjectFromStore` step with `output` set to populate the variable +4. **Activities not found**: Verify the naming convention `app:::` and that the `ActivityCollection` is registered in `config.ts` +5. **App interaction not found from `executeInteraction`**: Use the full app interaction id `app:::`, not a bare interaction name +6. **App child record creation fails with app type not found**: Use the resolved app type code in `client.objects.create`, not `client.types.getTypeByName()` if the type is app-contributed in-code +7. **Custom CLM/business enrichment races standard intake**: move the custom rule off `create` and trigger on a later `update` condition such as `status=completed` diff --git a/templates/plugin-template/.claude/skills/vertesia-gap-assessment/SKILL.md b/templates/plugin-template/.claude/skills/vertesia-gap-assessment/SKILL.md new file mode 100644 index 000000000..f66836cd2 --- /dev/null +++ b/templates/plugin-template/.claude/skills/vertesia-gap-assessment/SKILL.md @@ -0,0 +1,187 @@ +--- +name: vertesia-gap-assessment +description: Assess the gap between arbitrary requirements and existing Vertesia capabilities before proposing implementation work. Use when reviewing requirement documents, discovery notes, demo asks, or feature requests for a Vertesia app, plugin, workflow, or agent so you can separate native platform support, installed project capabilities, current app implementation, and true custom work. +--- + +# Vertesia Gap Assessment + +Use this skill before proposing implementation plans for a Vertesia solution. The goal is to avoid inventing custom work too early and to ground recommendations in four sources of truth: + +1. live project state +2. official Vertesia docs +3. current codebase +4. related local examples + +Read `references/sources-and-checks.md` at the start of the assessment. + +## What To Produce + +Produce a compact assessment with these sections: + +- Requirement summary +- Existing Vertesia capabilities relevant to the ask +- Existing project or app assets that can be reused +- Gap matrix: + - requirement + - native support + - project support + - app support + - custom work needed +- Recommended implementation path +- Risks or unknowns requiring verification + +Keep the distinction explicit between: + +- what Vertesia supports in general +- what the target project has installed or configured +- what the current app or plugin already uses + +## Workflow + +### 1. Normalize the requirements + +Extract the requested outcomes and group them into a short capability list. Use whatever groups fit the ask, but these are the default buckets: + +- intake +- repository and search +- workflow and approvals +- lifecycle and reminders +- reporting and analytics +- integrations +- assistant and agent behavior +- mobile or responsive UX +- admin and settings + +Do not jump to architecture until the requirement list is normalized. + +### 2. Check sources in the right order + +#### A. Live project state first + +Use the CLI or API to inspect what is actually present in the target Vertesia project: + +- active CLI profile +- installed workflow definitions +- installed workflow rules +- installed apps +- interactions available in the current project +- content objects or uploaded demo data when relevant + +If a command fails due to expired auth, refresh the profile and retry. + +If you inspect profiles with `vertesia profiles show`, do not echo API keys or tokens back to the user. Summarize only the safe fields you need. + +When the question involves app-defined types, interactions, templates, or remote activities, verify the exact runtime identifiers through the project APIs or the app package, not only through local code. App resources often have two useful names: + +- a local/query name used in search filters, such as `clm_contract` +- an app in-code identifier used in create/execute paths, such as `app::clm:ClmContract` + +#### B. Official docs second + +Use the Vertesia docs to determine platform-native capabilities before proposing custom code. The high-value pages are: + +- workflow activities catalog +- agent built-in tools +- agent runner overview +- plugin/custom app docs +- CLI reference + +Prefer standard workflows, built-in workflow activities, and built-in agent tools when they cover the requirement. + +#### C. Current codebase third + +Inspect what the current app already implements: + +- content types +- interactions +- tools and activities +- workflow specs +- UI routes and pages +- local skills and project docs + +For UI requirements, inspect both the local UI code and the available `@vertesia/ui` primitives before proposing custom components. A gap in the current app is not automatically a gap in the shared UI library. + +Do not confuse "possible in Vertesia" with "already wired into this app". + +#### D. Related local examples fourth + +Look for reusable patterns in: + +- nearby plugin repos +- project-local skills +- installed standard workflows in the target project +- prior implementations of similar flows + +Treat installed standard workflows as real reuse candidates, not as theoretical examples. + +### 3. Classify every requirement + +For each requirement, classify it as one of: + +- already implemented +- available natively in Vertesia +- partially covered and should be composed from native pieces +- requires custom plugin or app code +- requires external integration or project configuration + +Use the most conservative label that is still accurate. + +### 4. Recommend the right architectural home + +Use these defaults unless the evidence points elsewhere: + +- deterministic intake and side effects -> DSL workflow +- structured extraction -> interaction executed by workflow +- open-ended review, search, explanation, analysis -> agent +- standard repository operations -> built-in workflow activities or built-in agent tools +- domain-specific routing, linked-record creation, adapter logic -> custom plugin code only if native pieces do not cover it +- UX-only visualization or dashboards -> UI composition +- third-party system access -> integration or adapter layer + +For list/detail UI requirements, also decide where state must live. If users are expected to go from a table to a detail view and back without losing context, page-local state is usually the wrong recommendation. + +Do not assume an agent is the best orchestration model. Check built-in workflow activities first. + +Do not invent custom activities too early. Check the workflow activities catalog first. + +### 5. Write the gap matrix + +For each normalized requirement, capture: + +- requirement +- evidence or source +- current support status +- recommended implementation approach +- custom work still needed +- open unknowns + +Be explicit when the right answer is hybrid, for example: + +- standard workflow + custom enrichment +- built-in tools + custom UI +- deterministic workflow + agent assistant + +## Decision Rules + +- Docs are not enough. Verify installed definitions and project state with the CLI. +- Installed project capabilities are not the same as code already used by this app. +- Existing standard workflows should be inspected before creating new ones. +- Prefer reuse over replacement when a standard workflow covers the generic part and custom code only needs to handle domain enrichment. +- If a requirement depends on configuration, credentials, or app installation settings, call that out separately from code work. +- If standard intake is already installed, prefer adding domain enrichment after the standard workflow completes rather than starting a second workflow on the same `create` event and letting them race. +- CLI support and project runtime support are not the same thing. A CLI command may reject an app-defined type even when direct project APIs and the app runtime accept it. +- For app-defined interactions in workflows, `executeInteraction` typically needs the full app interaction id, not a bare interaction name. +- For app-defined child records created from custom activities or UI actions, prefer the resolved app type code used by the runtime create path. +- For UI work, classify "needs custom UI code" only after checking whether the shared `@vertesia/ui` library already provides the needed primitive. +- For list/detail UI work, inspect the route boundary and the persistence behavior of shared filter components. If `FilterProvider` or URL-backed filters are involved and state also survives navigation, plan a normalization or dedupe step so filter restoration does not create duplicates. +- For sortable, facet-driven repository tables, prefer one backend search path that owns rows, sort, and facets instead of mixing a simpler find path with a richer search path. + +## Output Style + +Keep the assessment compact and implementation-oriented. + +Prefer short grouped bullets and a small matrix over long prose. + +When useful, summarize the recommendation in one sentence: + +`Use for the generic path, then add for the domain-specific gap.` diff --git a/templates/plugin-template/.claude/skills/vertesia-gap-assessment/references/sources-and-checks.md b/templates/plugin-template/.claude/skills/vertesia-gap-assessment/references/sources-and-checks.md new file mode 100644 index 000000000..aa7dac324 --- /dev/null +++ b/templates/plugin-template/.claude/skills/vertesia-gap-assessment/references/sources-and-checks.md @@ -0,0 +1,198 @@ +# Sources And Checks + +Use this file as the concrete checklist when assessing requirements against Vertesia capabilities. + +## 1. Live Project State + +Check project reality before reading docs deeply. + +### CLI commands verified in this environment + +```bash +vertesia profiles show +vertesia profiles refresh +vertesia workflows definitions list +vertesia workflows definitions get +vertesia workflows rules list +vertesia workflows rules get +vertesia apps list-installed +vertesia apps get-installation +vertesia interactions +vertesia content list +vertesia content get +``` + +### Notes + +- `vertesia profiles show` includes sensitive tokens. Never repeat them in user-facing output. +- If workflow or app queries fail with `401 Token expired`, run `vertesia profiles refresh` and retry. +- After auth refresh, verify the existing dev server port, public tunnel, and installed app manifest endpoint before starting replacements. +- Installed workflows matter. In this environment, inspecting installed definitions surfaced reusable system workflows such as `StandardDocumentIntake`. + +### Questions to answer + +- Which profile and project are active? +- Which workflow definitions are installed? +- Which workflow rules are installed? +- Which apps are installed in the project? +- Which interactions are already available? +- Is there existing content/demo data that changes the implementation plan? + +### Resource identity checks + +For app-defined resources, verify the exact ids used by the runtime create/execute paths. + +- Confirm the installed app manifest endpoint is reachable and points to the expected package URL. +- For local development against a cloud project, verify the tunnel URL and the manifest `endpoint` before debugging missing app resources. +- List app types from the project, not just from local code. +- Check the direct app-type detail endpoint, not only the listing endpoint. +- Distinguish: + - query/search type name, for example `clm_contract` + - app in-code type id, for example `app::clm:ClmContract` + - app interaction id, for example `app::clm:ExtractContractMetadata` + +Do not assume the lowercase local type name is also the correct runtime create id. + +### CLI vs API checks + +- `vertesia content post` may validate only against stored/system types and reject app-defined types. +- If the CLI rejects an app-defined type, verify the project app-type APIs before assuming the app is broken. +- When the runtime accepts the app-defined type but the CLI does not, use a direct API uploader or an app-side upload flow. + +## 2. Official Vertesia Docs + +Use docs to determine what the platform supports natively. + +### Priority docs + +- Workflow activities catalog +- Agent built-in tools +- Agent runner overview +- Plugin/custom app docs +- CLI docs + +### What to look for + +- Standard workflow activities that replace custom orchestration code +- Built-in agent tools that replace custom repository/search wrappers +- Existing platform patterns for workflows, agents, search, scheduling, updates, and integrations + +### Reusable lessons + +- Built-in workflow activities often already cover extraction, interaction execution, document updates, progress messages, and embeddings. +- Built-in agent tools often already cover search, fetch, document CRUD, collection management, and optional workflow scheduling. +- Standard intake workflows often cover generic text extraction, property generation, and embeddings. Domain-specific enrichment should usually be staged after standard intake completes. + +## 3. Current Codebase + +Inspect the local app/plugin to see what is already implemented. + +### Default paths to inspect + +```text +src/tool-server/types/ +src/tool-server/interactions/ +src/tool-server/tools/ +src/tool-server/activities/ +src/tool-server/config.ts +src/ui/routes.tsx +src/ui/pages.tsx +workflow-specs/ +.claude/skills/ +``` + +### Questions to answer + +- Which content types already exist? +- Which interactions already exist? +- Which tools or remote activities already exist? +- Are there workflow specs in the repo, or only installed workflows in the project? +- Which UI pages already expose the behavior? +- Does the UI use the same identifiers for query/search and create/execute paths, or are those concerns currently conflated? +- For list/detail UX, where does the list state actually live relative to the route boundary? +- If filters are URL-backed, can the current state model remount and re-append the same filters on back-navigation? + +## 4. Related Examples + +Use nearby examples to reduce custom work. + +### Good sources + +- neighboring plugin repos +- project-local skills +- installed standard workflows +- local implementation examples that use the same Vertesia primitives + +### Example reuse patterns + +- standard workflow + custom post-processing +- built-in interaction execution + custom linked-record persistence +- built-in agent tools + custom assistant prompt +- standard document intake + post-intake enrichment rule on `update` when `status=completed` + +## 5. Classification Framework + +For each requirement, classify it as: + +- already implemented +- available natively in Vertesia +- partially covered and composable from native pieces +- requires custom plugin/app code +- requires external integration or project configuration + +Also record three support layers separately: + +- native platform support +- installed project support +- current app support + +## 6. Architecture Heuristics + +Use these defaults unless evidence shows a better fit: + +- deterministic intake, updates, retries, audit-friendly side effects -> workflow +- structured extraction with schema output -> interaction executed by workflow +- exploratory review, explanation, search assistance -> agent +- standard repository operations -> built-in workflow activities or built-in agent tools +- domain-specific routing and child-record creation -> custom plugin logic if native pieces stop short +- scheduling and outbound communication -> verify native support and project configuration before proposing custom code +- list/detail UX with preserved context -> lift state above the route boundary, persist scroll explicitly, and normalize URL-restored filters + +## 7. Debugging Heuristics + +When a create or execute path fails with "app type not found" or "interaction not found": + +1. verify the installed app manifest points to the correct `/api/package` endpoint +2. verify the public tunnel or deployment URL is reachable and returns the expected app package +3. verify the app package endpoint exposes the resource +4. verify the project app-resource listing endpoint sees it +5. verify the project app-resource detail endpoint resolves it +6. compare the id shape used in the failing payload with the id shape returned by the detail endpoint +7. test direct API create/execute before blaming the app logic + +When debugging locally against a cloud project, app-manifest and tunnel issues are often mistaken for type or interaction bugs. + +When back-navigation in a list/detail UI behaves badly: + +1. check whether filters, sort, and search live inside the list page component +2. check whether a shared filter component also restores from the URL on mount +3. dedupe or normalize filter writes if both persisted React state and URL restoration are active +4. persist scroll position in history state and restore it after the list layout is ready +5. if hover-reveal buttons are invisible, verify Tailwind variant classes are literal strings and not dynamically assembled template fragments + +When auth expires mid-debugging: + +1. refresh auth +2. verify the existing local dev server still responds +3. verify the current tunnel host still resolves +4. verify the installed app manifest still points to that live tunnel +5. only create a new dev server or tunnel if the current pair is invalid + +Do not let multiple quick tunnels and drifting local ports accumulate during one debugging session. + +When two workflows act on the same object: + +1. check installed rules, not just repo workflow specs +2. confirm which event each rule matches +3. avoid `create`-time races when a standard intake workflow already exists +4. move custom enrichment to a later `update` condition if the generic workflow should finish first diff --git a/templates/plugin-template/.claude/skills/vertesia-plugin/REFERENCE.md b/templates/plugin-template/.claude/skills/vertesia-plugin/REFERENCE.md index f651fe7d0..6eee589e4 100644 --- a/templates/plugin-template/.claude/skills/vertesia-plugin/REFERENCE.md +++ b/templates/plugin-template/.claude/skills/vertesia-plugin/REFERENCE.md @@ -103,6 +103,43 @@ pnpm build && pnpm start # Runs on port 3000 (or PORT env var) The Node server (`server-node.ts`) serves static files from `dist/` via `@hono/node-server/serve-static`. +### Tunnel-Based Dev Testing + +When testing a local plugin against a cloud Vertesia project: + +1. start the local HTTPS dev server +2. expose it publicly with a tunnel, for example: + +```bash +npx cloudflared tunnel --url https://localhost:5174 --no-tls-verify +``` + +3. update the installed app manifest so `endpoint` points to: + +```text +https:///api/package +``` + +4. verify: + +- the app manifest now shows the tunnel endpoint +- `GET /api/package?scope=types,interactions,activities` returns the expected app package + +If the project cannot resolve app-defined resources, treat tunnel reachability and manifest endpoint correctness as first-line checks. + +### Auth Expiry Discipline + +When the CLI or project auth expires during tunnel-based testing: + +1. refresh auth first +2. verify whether the current local dev server is still alive on its existing port +3. verify whether the current tunnel host is still reachable +4. verify whether the installed app manifest still points to that live tunnel +5. only create a new tunnel if the current one is actually dead +6. if a new tunnel is created, update the installed app manifest immediately + +Avoid creating a fresh `pnpm dev` process and a fresh quick tunnel by reflex. That leaves multiple ports and stale manifest endpoints in play, which commonly shows up as `Failed to fetch type` or `interaction not found` even though the local app code is fine. + ### Organization Access Restriction Set `VERTESIA_ALLOWED_ORGS` to restrict to specific orgs: diff --git a/templates/plugin-template/.claude/skills/vertesia-plugin/SKILL.md b/templates/plugin-template/.claude/skills/vertesia-plugin/SKILL.md index 2ef11ccbb..a64a64e6a 100644 --- a/templates/plugin-template/.claude/skills/vertesia-plugin/SKILL.md +++ b/templates/plugin-template/.claude/skills/vertesia-plugin/SKILL.md @@ -117,21 +117,46 @@ To create tools, skills, interactions, content types, or templates, use the **wr Each resource follows the same pattern: create files → export from collection → register in `config.ts`. -## UI Plugin - -For UI component APIs, routing, layout, styling, and agent conversation patterns, use the **vertesia-ui** skill. - -Key entry points: +## UI Plugin + +For UI component APIs, routing, layout, styling, and agent conversation patterns, use the **vertesia-ui** skill. + +When the task includes UI work, do not stop at "it renders". The UI pass should start with a `@vertesia/ui` component inventory and end with a conformance check for duplicated primitives such as raw tables, native selects, local page headers, and inline styles. + +Key entry points: - `src/ui/plugin.tsx` — Library entry for Vertesia host (exports default component receiving `{ slot }`) - `src/ui/main.tsx` — Standalone dev entry (VertesiaShell + AdminApp at `/`, plugin at `/app/`) - `src/ui/routes.tsx` — Route definitions - `src/ui/assets.ts` — `useAsset(path)` for URLs relative to the plugin bundle -## Authentication - -Tool endpoints receive JWT tokens via `Authorization: Bearer {token}`. The SDK validates automatically. Access the client via `const client = await context.getClient()` in tool `run()`. For full client API reference, use the **vertesia-api** skill. - -For organization access restriction and deployment details, see REFERENCE.md. +## Authentication + +Tool endpoints receive JWT tokens via `Authorization: Bearer {token}`. The SDK validates automatically. Access the client via `const client = await context.getClient()` in tool `run()`. For full client API reference, use the **vertesia-api** skill. + +For organization access restriction and deployment details, see REFERENCE.md. + +## Local Runtime Exposure + +For project-connected testing, the local plugin server is not enough by itself. The installed Vertesia app manifest must point to a reachable package endpoint. + +Use this sequence: + +1. run the local HTTPS dev server +2. expose it with a tunnel such as Cloudflare Tunnel +3. update the installed app manifest `endpoint` to `/api/package` +4. verify the app package from the public URL before debugging project-side failures + +If the project does not see updated types, interactions, or activities, check the manifest endpoint before changing app code. + +When auth expires during this flow: + +1. refresh auth first +2. verify the currently running dev server port still works +3. verify the current public tunnel still resolves +4. verify the installed app manifest still points to that live tunnel +5. only then decide whether a new server or tunnel is needed + +Do not keep spawning new local ports and quick tunnels after an auth failure. The project can easily end up pointing at a dead tunnel even while the local app appears healthy. ## Key Dependencies diff --git a/templates/plugin-template/.claude/skills/vertesia-ui/SKILL.md b/templates/plugin-template/.claude/skills/vertesia-ui/SKILL.md index ca596677f..6d086ea2e 100644 --- a/templates/plugin-template/.claude/skills/vertesia-ui/SKILL.md +++ b/templates/plugin-template/.claude/skills/vertesia-ui/SKILL.md @@ -9,6 +9,36 @@ React UI built with React 19, Tailwind CSS 4, and `@vertesia/ui` components. For the full component API reference, see also `composableai/packages/ui/llms.txt` (shipped with the npm package). +For a reusable list/detail table example with backend search, sort, facets, inline filters, and back-navigation preservation, see `references/generic-table-pattern.md`. + +## Required First Step: Component Inventory + +Before writing or refactoring UI code, explicitly check whether `@vertesia/ui` already provides the surface you need. + +At minimum, search for an existing component in these buckets: + +- `@vertesia/ui/core` +- `@vertesia/ui/layout` +- `@vertesia/ui/features` + +If a suitable component exists, use it. Do not reimplement it with raw HTML just because the local version is faster to type. + +This is especially strict for: + +- tables +- page headers +- filters +- modals +- tabs +- badges +- buttons +- selects +- text inputs +- side panels +- empty/loading/error states + +If you still introduce a custom wrapper, state which existing `@vertesia/ui` component you checked and why it was insufficient. + ## Import Paths ```tsx @@ -99,6 +129,8 @@ import { Table, TBody, THead, Th, Tr, Td } from '@vertesia/ui/core'; `TBody` renders loading skeletons when `isLoading=true`. **Only use `isLoading` for initial empty-state load**, not for "load more" — show a separate `` below the table for appending. +Use this instead of custom `` wrappers unless there is a documented gap. + ### Infinite Scroll (Lazy Loading) ```tsx @@ -213,6 +245,60 @@ const path = useLocation().pathname; // Current path Go to Items ``` +### List / Detail Preservation + +If a list page links to a detail page and users are expected to go back, do not keep the list state inside the list page component. + +Preserve these concerns above the route boundary when they matter: + +- active filters +- search query +- sort +- pagination or loaded results +- row selection +- scroll position + +Use a provider above `NestedRouterProvider` or above the list/detail route split. Do not use a module-level singleton. + +If `FilterProvider` is involved, remember that it restores filters from the URL on mount. If your list state also survives route changes, normalize or dedupe filter writes at the provider boundary so the same filter does not get appended repeatedly on back-navigation. + +For scroll restoration: + +- persist the list scroll position in provider state and `window.history.state.data` +- restore it in `useLayoutEffect` +- wait until the list has rendered before restoring, typically with `requestAnimationFrame` + +Do not treat list/detail navigation as a cosmetic issue. If filters and scroll reset on back, the UX is wrong. + +### Search vs Find For Table Surfaces + +For table/list surfaces that need any combination of: + +- full-text search +- backend sort +- backend facets + +use one backend `search` path consistently. + +Do not mix `find` for the default state with `search` for filtered states if the page exposes sort and facet-driven filtering. That creates different backend behavior depending on UI state and weakens the table contract. + +Use `find` only for simple exact-match fetches that do not require backend sort, full-text behavior, or facets. + +## Completion Check + +Before calling a UI task complete, run a short conformance pass: + +1. any raw `
` where `Table` / `THead` / `TBody` should be used? +2. any native ` + + + + + + + navigate(`/items/${id}`)} + /> + + ); +} +``` + +## Table Pattern + +Use `Table`, `THead`, and `TBody` directly. + +```tsx +
+ + + + + + + + + {items.map((item) => ( + onOpen(item.id)} + > + + + ))} + +
+
+ {item.title} + onFilterValue("title", item.title)} + /> +
+
+``` + +## Inline Filter Button Pattern + +Do not assemble Tailwind hover classes dynamically. + +Bad: + +```tsx +className={`opacity-0 group-hover/${groupName}:opacity-100`} +``` + +Good: + +```tsx +const hoverClass = { + title: "group-hover/title:opacity-100", + owner: "group-hover/owner:opacity-100", +}[groupName]; +``` + +```tsx +function InlineFilterButton({ + tooltip, + hoverClass, + onClick, +}: { + tooltip: string; + hoverClass: string; + onClick: () => void; +}) { + return ( + + + + ); +} +``` + +## Filter Deduplication Rule + +If both of these are true: + +- the list state survives route changes +- `FilterProvider` restores filters from the URL on mount + +then filter writes must be normalized or deduped. + +Otherwise, going to detail and back can duplicate chips and query params. + +```tsx +function dedupeFilters(filters: Filter[]) { + const seen = new Set(); + const deduped: Filter[] = []; + + for (const filter of filters) { + const normalizedValue = Array.isArray(filter.value) + ? filter.value.map((entry) => typeof entry === "string" ? entry : `${entry.value}|${entry.label || ""}`) + : []; + const key = [ + filter.name, + filter.type, + filter.multiple ? "multi" : "single", + ...normalizedValue, + ].join("::"); + + if (seen.has(key)) continue; + seen.add(key); + deduped.push(filter); + } + + return deduped; +} +``` + +## Scroll Persistence Pattern + +```tsx +function persistScrollTop(scrollTop: number) { + const state = window.history.state || {}; + window.history.replaceState({ + ...state, + data: { + ...(state.data || {}), + listScrollTop: scrollTop, + }, + }, ""); +} + +function readScrollTop() { + const state = window.history.state as { data?: { listScrollTop?: number } } | null; + return state?.data?.listScrollTop; +} +``` + +Restore in `useLayoutEffect`, not plain `useEffect`. + +## Checklist + +Before calling a list/detail table done: + +1. does the table use `Table`, `THead`, `TBody` directly? +2. does backend search own rows, sort, and facets? +3. is list state above the route boundary? +4. are URL-restored filters deduped or normalized? +5. is scroll restored on back-navigation? +6. are hover-reveal classes literal/static so Tailwind emits them? From 8bd430dbe7d545a5bceb2c0cd5ef5b8f5e9602cf Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Fri, 8 May 2026 03:31:30 +0900 Subject: [PATCH 50/75] fix: update tsconfig files to ignore deprecations and improve workflow execution handling --- llumiverse | 2 +- packages/build-tools/tsconfig.json | 1 + packages/client/tsconfig.json | 1 + packages/common/src/json-schema.ts | 1 - packages/common/tsconfig.json | 3 ++- packages/converters/tsconfig.json | 3 ++- packages/json/tsconfig.json | 3 ++- packages/memory-commands/tsconfig.json | 3 ++- packages/memory/tsconfig.json | 3 ++- packages/tools-sdk/tsconfig.json | 3 ++- .../workflow/src/activities/executeInteraction.ts | 12 ++++++++---- packages/workflow/src/activities/rateLimiter.ts | 4 ++-- packages/workflow/src/dsl/setup/ActivityContext.ts | 4 ++-- packages/workflow/src/utils/storage.ts | 4 ++-- .../plugin-template/src/tool-server/server-node.ts | 3 ++- 15 files changed, 31 insertions(+), 19 deletions(-) diff --git a/llumiverse b/llumiverse index 211beea77..fdf0ca139 160000 --- a/llumiverse +++ b/llumiverse @@ -1 +1 @@ -Subproject commit 211beea779db480f845c5f5a2df1957d6b6e5f10 +Subproject commit fdf0ca1398e2a0ef24a291f34b9336790678ec91 diff --git a/packages/build-tools/tsconfig.json b/packages/build-tools/tsconfig.json index 8cf9a931e..6af4d6a06 100644 --- a/packages/build-tools/tsconfig.json +++ b/packages/build-tools/tsconfig.json @@ -16,6 +16,7 @@ "useDefineForClassFields": true, "module": "ESNext", "moduleResolution": "Bundler", + "ignoreDeprecations": "6.0", "skipLibCheck": true, "resolveJsonModule": true, "isolatedModules": true, diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json index 7963184fc..968a6866e 100644 --- a/packages/client/tsconfig.json +++ b/packages/client/tsconfig.json @@ -20,6 +20,7 @@ /* Bundler mode */ "module": "ESNext", "moduleResolution": "Bundler", + "ignoreDeprecations": "6.0", //"allowImportingTsExtensions": true, //"verbatimModuleSyntax": true, //"noEmit": false, diff --git a/packages/common/src/json-schema.ts b/packages/common/src/json-schema.ts index 5fdacb732..d3dd665b2 100644 --- a/packages/common/src/json-schema.ts +++ b/packages/common/src/json-schema.ts @@ -2,7 +2,6 @@ export type { JSONSchema, JSONSchemaArray, JSONSchemaObject, - JSONSchemaProperties, JSONSchemaType, JSONSchemaTypeName, } from "@llumiverse/common"; diff --git a/packages/common/tsconfig.json b/packages/common/tsconfig.json index 7c57abbe0..9db02139d 100644 --- a/packages/common/tsconfig.json +++ b/packages/common/tsconfig.json @@ -19,6 +19,7 @@ /* Bundler mode */ "module": "ESNext", "moduleResolution": "Bundler", + "ignoreDeprecations": "6.0", //"allowImportingTsExtensions": true, //"verbatimModuleSyntax": true, //"noEmit": false, @@ -47,4 +48,4 @@ "path": "../../llumiverse/common/tsconfig.json" }, ] -} \ No newline at end of file +} diff --git a/packages/converters/tsconfig.json b/packages/converters/tsconfig.json index 4e7942931..849404605 100644 --- a/packages/converters/tsconfig.json +++ b/packages/converters/tsconfig.json @@ -19,6 +19,7 @@ /* Bundler mode */ "module": "ESNext", "moduleResolution": "Bundler", + "ignoreDeprecations": "6.0", //"allowImportingTsExtensions": true, //"verbatimModuleSyntax": true, //"noEmit": false, @@ -42,4 +43,4 @@ "lib", "test" ], -} \ No newline at end of file +} diff --git a/packages/json/tsconfig.json b/packages/json/tsconfig.json index 7c57abbe0..9db02139d 100644 --- a/packages/json/tsconfig.json +++ b/packages/json/tsconfig.json @@ -19,6 +19,7 @@ /* Bundler mode */ "module": "ESNext", "moduleResolution": "Bundler", + "ignoreDeprecations": "6.0", //"allowImportingTsExtensions": true, //"verbatimModuleSyntax": true, //"noEmit": false, @@ -47,4 +48,4 @@ "path": "../../llumiverse/common/tsconfig.json" }, ] -} \ No newline at end of file +} diff --git a/packages/memory-commands/tsconfig.json b/packages/memory-commands/tsconfig.json index c92e81cb2..1cc142415 100644 --- a/packages/memory-commands/tsconfig.json +++ b/packages/memory-commands/tsconfig.json @@ -19,6 +19,7 @@ /* Bundler mode */ "module": "ESNext", "moduleResolution": "Bundler", + "ignoreDeprecations": "6.0", //"allowImportingTsExtensions": true, //"verbatimModuleSyntax": true, //"noEmit": false, @@ -47,4 +48,4 @@ "path": "../memory/tsconfig.json" } ] -} \ No newline at end of file +} diff --git a/packages/memory/tsconfig.json b/packages/memory/tsconfig.json index 6be53762b..ac97d8e71 100644 --- a/packages/memory/tsconfig.json +++ b/packages/memory/tsconfig.json @@ -19,6 +19,7 @@ /* Bundler mode */ "module": "ESNext", "moduleResolution": "Bundler", + "ignoreDeprecations": "6.0", //"allowImportingTsExtensions": true, //"verbatimModuleSyntax": true, //"noEmit": false, @@ -46,4 +47,4 @@ { "path": "../converters/tsconfig.json" }, { "path": "../json/tsconfig.json" } ] -} \ No newline at end of file +} diff --git a/packages/tools-sdk/tsconfig.json b/packages/tools-sdk/tsconfig.json index 6c40dc684..4beabacf7 100644 --- a/packages/tools-sdk/tsconfig.json +++ b/packages/tools-sdk/tsconfig.json @@ -20,6 +20,7 @@ /* Bundler mode */ "module": "ESNext", "moduleResolution": "Bundler", + "ignoreDeprecations": "6.0", //"allowImportingTsExtensions": true, //"verbatimModuleSyntax": true, //"noEmit": false, @@ -53,4 +54,4 @@ , { "path": "../../llumiverse/common/tsconfig.json" } ] -} \ No newline at end of file +} diff --git a/packages/workflow/src/activities/executeInteraction.ts b/packages/workflow/src/activities/executeInteraction.ts index a6d070d10..86a1fe802 100644 --- a/packages/workflow/src/activities/executeInteraction.ts +++ b/packages/workflow/src/activities/executeInteraction.ts @@ -170,7 +170,7 @@ export async function executeInteraction(payload: DSLActivityExecutionPayload> { } get runId() { - const runId = activityInfo().workflowExecution.runId; + const runId = activityInfo().workflowExecution?.runId; if (!runId) { log.error("No runId found in activityInfo"); throw new WorkflowParamNotFoundError( @@ -88,7 +88,7 @@ export class ActivityContext> { } get workflowId() { - const workflowId = activityInfo().workflowExecution.workflowId; + const workflowId = activityInfo().workflowExecution?.workflowId; if (!workflowId) { log.error("No workflowId found in activityInfo"); throw new WorkflowParamNotFoundError( diff --git a/packages/workflow/src/utils/storage.ts b/packages/workflow/src/utils/storage.ts index a9c600f45..936a7e121 100644 --- a/packages/workflow/src/utils/storage.ts +++ b/packages/workflow/src/utils/storage.ts @@ -27,7 +27,7 @@ export async function saveAgentArtifact( mimeType: string = "application/json", storageId?: string, ) { - const id = storageId || activityInfo().workflowExecution.runId; + const id = storageId || activityInfo().workflowExecution!.runId; const ext = mime.getExtension(mimeType); if (!name) { throw ApplicationFailure.nonRetryable(`Name is required`); @@ -55,7 +55,7 @@ export async function saveAgentArtifact( } export async function fetchAgentArtifact(client: VertesiaClient, name: string, storageId?: string) { - const id = storageId || activityInfo().workflowExecution.runId; + const id = storageId || activityInfo().workflowExecution!.runId; const filePath = agentStoragePath(id) + "/" + name; return fetchBlobAsBuffer(client, filePath); } diff --git a/templates/plugin-template/src/tool-server/server-node.ts b/templates/plugin-template/src/tool-server/server-node.ts index 153efc317..1710e35bf 100644 --- a/templates/plugin-template/src/tool-server/server-node.ts +++ b/templates/plugin-template/src/tool-server/server-node.ts @@ -17,7 +17,8 @@ console.log(`Starting Tool Server on port ${port}...`); console.log(`API endpoint: http://localhost:${port}/api`); console.log(`Web UI: http://localhost:${port}/`); -server.get('*', serveStatic({ root: './dist' })); +// Hono's handler type carries a route symbol, so duplicate workspace type instances can be incompatible. +server.all('*', serveStatic({ root: './dist' }) as never); serve({ fetch: server.fetch, From 4db99b987c89f05bed9717aa3041c3c68f6c0782 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Vachette?= <5880528+michaelva@users.noreply.github.com> Date: Fri, 8 May 2026 10:05:03 +0900 Subject: [PATCH 51/75] improve claude resources --- .../skills/vertesia-plugin/REFERENCE.md | 2 +- .../.claude/skills/vertesia-plugin/SKILL.md | 74 +++---- .../REFERENCE.md} | 199 ++++++------------ .../vertesia-tool-server-resource/SKILL.md | 109 ++++++++++ .../.claude/skills/vertesia-ui/SKILL.md | 98 +-------- .../skills/vertesia-ui/references/security.md | 89 ++++++++ templates/plugin-template/CLAUDE.md | 106 +++++----- 7 files changed, 356 insertions(+), 321 deletions(-) rename templates/plugin-template/.claude/skills/{write-tool-server-resource/SKILL.md => vertesia-tool-server-resource/REFERENCE.md} (61%) create mode 100644 templates/plugin-template/.claude/skills/vertesia-tool-server-resource/SKILL.md create mode 100644 templates/plugin-template/.claude/skills/vertesia-ui/references/security.md diff --git a/templates/plugin-template/.claude/skills/vertesia-plugin/REFERENCE.md b/templates/plugin-template/.claude/skills/vertesia-plugin/REFERENCE.md index 6eee589e4..79ea6b7d4 100644 --- a/templates/plugin-template/.claude/skills/vertesia-plugin/REFERENCE.md +++ b/templates/plugin-template/.claude/skills/vertesia-plugin/REFERENCE.md @@ -8,7 +8,7 @@ Additional details for the plugin architecture. Referenced from SKILL.md. - [CSS Customization](#css-customization) - [Deployment](#deployment) -For resource creation code examples (tools, skills, interactions, types, templates), use the **write-tool-server-resource** skill. +For resource creation code examples (tools, skills, interactions, types, templates), use the **vertesia-tool-server-resource** skill. --- diff --git a/templates/plugin-template/.claude/skills/vertesia-plugin/SKILL.md b/templates/plugin-template/.claude/skills/vertesia-plugin/SKILL.md index a64a64e6a..4b19a216a 100644 --- a/templates/plugin-template/.claude/skills/vertesia-plugin/SKILL.md +++ b/templates/plugin-template/.claude/skills/vertesia-plugin/SKILL.md @@ -1,6 +1,6 @@ --- name: vertesia-plugin -description: Reference for plugin architecture, dual build system, import hooks, and deployment. Use when understanding plugin structure or build configuration. For creating resources use write-tool-server-resource; for UI use vertesia-ui; for client API use vertesia-api. +description: Reference for plugin architecture, dual build system, import hooks, and deployment. Use when understanding plugin structure or build configuration. For creating resources use vertesia-tool-server-resource; for UI use vertesia-ui; for client API use vertesia-api. --- # Vertesia Plugin Development @@ -113,50 +113,50 @@ These Rollup import transformations only work in `src/tool-server/` code: ## Creating Resources -To create tools, skills, interactions, content types, or templates, use the **write-tool-server-resource** skill. It provides step-by-step scaffolding with full code examples. +To create tools, skills, interactions, content types, or templates, use the **vertesia-tool-server-resource** skill. It provides step-by-step scaffolding with full code examples. Each resource follows the same pattern: create files → export from collection → register in `config.ts`. -## UI Plugin - -For UI component APIs, routing, layout, styling, and agent conversation patterns, use the **vertesia-ui** skill. - -When the task includes UI work, do not stop at "it renders". The UI pass should start with a `@vertesia/ui` component inventory and end with a conformance check for duplicated primitives such as raw tables, native selects, local page headers, and inline styles. - -Key entry points: +## UI Plugin + +For UI component APIs, routing, layout, styling, and agent conversation patterns, use the **vertesia-ui** skill. + +When the task includes UI work, do not stop at "it renders". The UI pass should start with a `@vertesia/ui` component inventory and end with a conformance check for duplicated primitives such as raw tables, native selects, local page headers, and inline styles. + +Key entry points: - `src/ui/plugin.tsx` — Library entry for Vertesia host (exports default component receiving `{ slot }`) - `src/ui/main.tsx` — Standalone dev entry (VertesiaShell + AdminApp at `/`, plugin at `/app/`) - `src/ui/routes.tsx` — Route definitions - `src/ui/assets.ts` — `useAsset(path)` for URLs relative to the plugin bundle -## Authentication - -Tool endpoints receive JWT tokens via `Authorization: Bearer {token}`. The SDK validates automatically. Access the client via `const client = await context.getClient()` in tool `run()`. For full client API reference, use the **vertesia-api** skill. - -For organization access restriction and deployment details, see REFERENCE.md. - -## Local Runtime Exposure - -For project-connected testing, the local plugin server is not enough by itself. The installed Vertesia app manifest must point to a reachable package endpoint. - -Use this sequence: - -1. run the local HTTPS dev server -2. expose it with a tunnel such as Cloudflare Tunnel -3. update the installed app manifest `endpoint` to `/api/package` -4. verify the app package from the public URL before debugging project-side failures - -If the project does not see updated types, interactions, or activities, check the manifest endpoint before changing app code. - -When auth expires during this flow: - -1. refresh auth first -2. verify the currently running dev server port still works -3. verify the current public tunnel still resolves -4. verify the installed app manifest still points to that live tunnel -5. only then decide whether a new server or tunnel is needed - -Do not keep spawning new local ports and quick tunnels after an auth failure. The project can easily end up pointing at a dead tunnel even while the local app appears healthy. +## Authentication + +Tool endpoints receive JWT tokens via `Authorization: Bearer {token}`. The SDK validates automatically. Access the client via `const client = await context.getClient()` in tool `run()`. For full client API reference, use the **vertesia-api** skill. + +For organization access restriction and deployment details, see REFERENCE.md. + +## Local Runtime Exposure + +For project-connected testing, the local plugin server is not enough by itself. The installed Vertesia app manifest must point to a reachable package endpoint. + +Use this sequence: + +1. run the local HTTPS dev server +2. expose it with a tunnel such as Cloudflare Tunnel +3. update the installed app manifest `endpoint` to `/api/package` +4. verify the app package from the public URL before debugging project-side failures + +If the project does not see updated types, interactions, or activities, check the manifest endpoint before changing app code. + +When auth expires during this flow: + +1. refresh auth first +2. verify the currently running dev server port still works +3. verify the current public tunnel still resolves +4. verify the installed app manifest still points to that live tunnel +5. only then decide whether a new server or tunnel is needed + +Do not keep spawning new local ports and quick tunnels after an auth failure. The project can easily end up pointing at a dead tunnel even while the local app appears healthy. ## Key Dependencies diff --git a/templates/plugin-template/.claude/skills/write-tool-server-resource/SKILL.md b/templates/plugin-template/.claude/skills/vertesia-tool-server-resource/REFERENCE.md similarity index 61% rename from templates/plugin-template/.claude/skills/write-tool-server-resource/SKILL.md rename to templates/plugin-template/.claude/skills/vertesia-tool-server-resource/REFERENCE.md index dd6ac37f4..c8491c89a 100644 --- a/templates/plugin-template/.claude/skills/write-tool-server-resource/SKILL.md +++ b/templates/plugin-template/.claude/skills/vertesia-tool-server-resource/REFERENCE.md @@ -1,41 +1,24 @@ ---- -name: write-tool-server-resource -description: Creates tools, skills, interactions, content types, and rendering templates for the Vertesia plugin tool server. Handles file scaffolding, collection registration, and config.ts wiring. Use when adding new tool server resources to this plugin. ---- - -# Write Tool Server Resource +# Write Tool Server Resource — Code Reference -Step-by-step guide for creating tool server resources. Each resource follows the same workflow: -1. Create files in the appropriate `src/tool-server///` directory -2. Export from the collection's `index.ts` -3. Register the collection in `config.ts` (if new collection) +Full code examples for each resource type. SKILL.md has the workflow and decision-points; this file has the templates you copy from. -**Important conventions:** -- All imports use `.js` extensions: `import { x } from "./foo.js"` -- Use `satisfies` for type validation -- Icons are SVG strings exported as default from `.ts` files -- Import hooks (`?skill`, `?skills`, `?prompt`, `?template`, `?templates`) only work in Rollup-compiled code -- Snake_case for resource names (`my_tool`), PascalCase for TypeScript exports (`MyTool`) +## Table of Contents -For full code examples, see the `examples/` directories under each resource type. +- [Tool](#tool) +- [Skill](#skill) +- [Interaction (template-based)](#interaction-template-based) +- [Interaction (code-based)](#interaction-code-based) +- [Content Type](#content-type) +- [Rendering Template](#rendering-template) +- [Collection registration & icons](#collection-registration--icons) --- -## Creating a Tool +## Tool -### File structure - -``` -src/tool-server/tools/// - schema.ts # TypeScript interface + JSONSchema - index.ts # Tool definition (satisfies Tool) - .ts # Implementation logic -``` - -### Step 1: Define the schema +### `schema.ts` ```typescript -// schema.ts import { JSONSchema } from "@llumiverse/common"; export interface MyToolParams { @@ -53,10 +36,9 @@ export const Schema = { } satisfies JSONSchema; ``` -### Step 2: Implement the logic +### `.ts` ```typescript -// my-impl.ts import { ToolExecutionContext, ToolExecutionPayload } from "@vertesia/tools-sdk"; import { ToolResultContent } from "@vertesia/common"; import { type MyToolParams } from "./schema.js"; @@ -80,10 +62,9 @@ export async function myToolRun( } ``` -### Step 3: Define the tool +### `index.ts` (tool definition) ```typescript -// index.ts import { Tool } from "@vertesia/tools-sdk"; import { myToolRun } from "./my-impl.js"; import { MyToolParams, Schema } from "./schema.js"; @@ -96,10 +77,9 @@ export const MyTool = { } satisfies Tool; ``` -### Step 4: Add to collection +### Collection (`tools//index.ts`) ```typescript -// src/tool-server/tools//index.ts import { ToolCollection } from "@vertesia/tools-sdk"; import { MyTool } from "./my-tool/index.js"; import icon from "./icon.svg.js"; @@ -113,25 +93,11 @@ export const MyTools = new ToolCollection({ }); ``` -### Step 5: Register in config.ts - -Add the collection to the `tools` array in `src/tool-server/tools/index.ts`, which is imported by `config.ts`. - --- -## Creating a Skill +## Skill -### File structure - -``` -src/tool-server/skills/// - SKILL.md # Skill definition (YAML frontmatter + instructions) - properties.ts # Optional: runtime properties (isEnabled) - *.tsx # Optional: widgets (compiled to dist/widgets/) - *.py, *.js # Optional: scripts (copied to dist/scripts/) -``` - -### Step 1: Write SKILL.md +### `SKILL.md` ```markdown --- @@ -152,18 +118,18 @@ Describe the behavior, output format, and constraints. ``` **Frontmatter fields:** -- `name` (required): Snake_case identifier -- `description` (required): What the skill does -- `title`: Display name -- `keywords`: Trigger auto-activation when matched -- `tools`: Related tools to unlock when skill is active -- `language`/`packages`: For code execution skills + +- `name` (required): snake_case identifier +- `description` (required): what the skill does +- `title`: display name +- `keywords`: trigger auto-activation when matched +- `tools`: related tools to unlock when skill is active +- `language` / `packages`: for code-execution skills - `widgets`: UI widgets to render -### Step 2 (optional): Add runtime properties +### Optional `properties.ts` ```typescript -// properties.ts import { SkillDefinition, ToolUseContext } from "@vertesia/tools-sdk"; export default { @@ -174,10 +140,10 @@ export default { } satisfies Partial; ``` -### Step 3: Collection uses auto-discovery +### Collection auto-discovery ```typescript -// src/tool-server/skills//index.ts +// skills//index.ts import { SkillCollection } from "@vertesia/tools-sdk"; import skills from "./all?skills"; @@ -189,27 +155,12 @@ export const MySkills = new SkillCollection({ }); ``` -Skills are auto-discovered — no need to manually import each one. - --- -## Creating an Interaction - -Two approaches: **template-based** (prompt in `.hbs` file) or **code-based** (prompts inline). +## Interaction (template-based) -### File structure (template-based) +### `prompt.hbs` -``` -src/tool-server/interactions/// - index.ts # InteractionSpec - prompt.hbs # Handlebars prompt template with YAML frontmatter - prompt_schema.ts # JSONSchema for template variables - result_schema.ts # JSONSchema for structured output -``` - -### Template-based interaction - -**Prompt template:** ```handlebars {{!-- prompt.hbs --}} --- @@ -221,9 +172,9 @@ Analyze the following content: {{input}} Please provide a {{format}} summary. ``` -**Prompt schema:** +### `prompt_schema.ts` + ```typescript -// prompt_schema.ts import { JSONSchema } from "@llumiverse/common"; export default { @@ -236,9 +187,9 @@ export default { } satisfies JSONSchema; ``` -**Result schema:** +### `result_schema.ts` + ```typescript -// result_schema.ts import { JSONSchema } from "@llumiverse/common"; export default { @@ -251,9 +202,9 @@ export default { } satisfies JSONSchema; ``` -**Interaction spec:** +### `index.ts` (interaction spec) + ```typescript -// index.ts import { InteractionSpec } from "@vertesia/common"; import PROMPT from "./prompt.hbs?prompt"; import result_schema from "./result_schema.js"; @@ -268,10 +219,13 @@ export default { } satisfies InteractionSpec; ``` -### Code-based interaction (for agents/conversations) +--- + +## Interaction (code-based) + +For agents and conversational interactions (no `.hbs` file): ```typescript -// index.ts import { PromptRole } from "@llumiverse/common"; import type { InteractionSpec } from "@vertesia/common"; import { TemplateType } from "@vertesia/common"; @@ -299,10 +253,9 @@ export default { } satisfies InteractionSpec; ``` -### Add to collection +### Collection (`interactions//index.ts`) ```typescript -// src/tool-server/interactions//index.ts import { InteractionCollection } from "@vertesia/tools-sdk"; import analyzeContent from "./analyze_content/index.js"; import icon from "./icon.svg.js"; @@ -318,21 +271,11 @@ export const MyInteractions = new InteractionCollection({ --- -## Creating a Content Type +## Content Type -### File structure - -``` -src/tool-server/types// - index.ts # ContentTypesCollection - icon.svg.ts # Collection icon - .ts # InCodeTypeSpec (one file per type) -``` - -### Type definition +### `.ts` ```typescript -// my-type.ts import { InCodeTypeSpec } from "@vertesia/common"; export const MyType = { @@ -359,10 +302,9 @@ export const MyType = { } satisfies InCodeTypeSpec; ``` -### Collection +### Collection (`types//index.ts`) ```typescript -// src/tool-server/types//index.ts import { ContentTypesCollection } from "@vertesia/tools-sdk"; import { MyType } from "./my-type.js"; import icon from "./icon.svg.js"; @@ -378,17 +320,9 @@ export const MyTypes = new ContentTypesCollection({ --- -## Creating a Rendering Template - -### File structure +## Rendering Template -``` -src/tool-server/templates/// - TEMPLATE.md # Template definition (YAML frontmatter + instructions) - *.svg, *.latex # Asset files (auto-discovered, copied to dist/templates/) -``` - -### TEMPLATE.md +### `TEMPLATE.md` ```markdown --- @@ -404,23 +338,24 @@ Instructions for the document generation system. ## Available Variables -- `{{title}}` - Report title -- `{{author}}` - Author name -- `{{date}}` - Report date +- `{{title}}` — Report title +- `{{author}}` — Author name +- `{{date}}` — Report date ``` **Frontmatter fields:** -- `description` (required): What this template generates + +- `description` (required): what this template generates - `type` (required): `'document'` or `'presentation'` -- `title`: Display name -- `tags`: Categorization tags +- `title`: display name +- `tags`: categorization tags Asset files (SVG, LaTeX, PNG) in the same directory are auto-discovered and copied to `dist/templates/`. -### Collection uses auto-discovery +### Collection auto-discovery ```typescript -// src/tool-server/templates//index.ts +// templates//index.ts import { RenderingTemplateCollection } from "@vertesia/tools-sdk"; import templates from './all?templates'; @@ -434,13 +369,9 @@ export const MyTemplates = new RenderingTemplateCollection({ --- -## Collection Registration - -All collections must be wired into the server through `config.ts`. +## Collection registration & icons -### Adding to an existing collection type - -Add your collection to the array in `src/tool-server//index.ts`: +### Adding a collection to its type's index ```typescript // src/tool-server/tools/index.ts @@ -450,26 +381,14 @@ import { MyTools } from "./my-collection/index.js"; export const tools = [ExampleTools, MyTools]; ``` -### Creating a new collection type (first of its kind) - -If `src/tool-server//index.ts` doesn't have your collection yet, add it there. The `config.ts` already imports from these index files. +`config.ts` already imports from these per-type index files, so no further wiring is needed once the new collection is in the array. -### Icon file +### `icon.svg.ts` Each collection needs an SVG icon as a default string export: ```typescript -// icon.svg.ts export default ` `; ``` - -## Verification - -After creating a resource: - -1. Build: `pnpm build:server` -2. Start: `pnpm start` -3. Check the admin UI at `http://localhost:3000/` — your resource should appear -4. Check the API endpoint: `curl http://localhost:3000/api/tools` (or `/api/skills`, `/api/interactions`, `/api/types`, `/api/templates`) diff --git a/templates/plugin-template/.claude/skills/vertesia-tool-server-resource/SKILL.md b/templates/plugin-template/.claude/skills/vertesia-tool-server-resource/SKILL.md new file mode 100644 index 000000000..242ed6949 --- /dev/null +++ b/templates/plugin-template/.claude/skills/vertesia-tool-server-resource/SKILL.md @@ -0,0 +1,109 @@ +--- +name: vertesia-tool-server-resource +description: Creates tools, skills, interactions, content types, and rendering templates for the Vertesia plugin tool server. Handles file scaffolding, collection registration, and config.ts wiring. Use when adding new tool server resources to this plugin. +--- + +# Vertesia Tool Server Resource + +Step-by-step guide for creating tool server resources. Each resource follows the same workflow: + +1. Create files in the appropriate `src/tool-server///` directory +2. Export from the collection's `index.ts` +3. Register the collection in `src/tool-server//index.ts` (only when adding a new collection) + +For full code templates of every resource type, see `REFERENCE.md`. + +## Conventions + +- All imports use `.js` extensions: `import { x } from "./foo.js"` +- Use `satisfies` for type validation (`{} satisfies Tool`, `{} satisfies InCodeTypeSpec`, …) +- Icons are SVG strings exported as default from `.ts` files +- Import hooks (`?skill`, `?skills`, `?prompt`, `?template`, `?templates`, `?raw`) only work in Rollup-compiled tool-server code, **not** in UI code +- Snake_case for resource names (`my_tool`, `my_type`); PascalCase for TypeScript exports (`MyTool`, `MyType`) +- See `src/tool-server//examples/` for working starter code + +## Resource types + +### Tool + +Three files in `src/tool-server/tools///`: + +| File | Purpose | +|------|---------| +| `schema.ts` | TypeScript interface + JSONSchema (`satisfies JSONSchema`) | +| `.ts` | The `run` function — uses `ToolExecutionPayload

`, returns `ToolResultContent` | +| `index.ts` | `Tool

` definition (`satisfies Tool`) | + +Then export from `tools//index.ts` as a `ToolCollection`. + +→ Code in `REFERENCE.md` § Tool. + +### Skill + +Files in `src/tool-server/skills///`: + +| File | Required | Purpose | +|------|----------|---------| +| `SKILL.md` | yes | YAML frontmatter + instructions for the agent | +| `properties.ts` | no | Runtime gating (`isEnabled`) | +| `*.tsx` | no | Widgets (compiled to `dist/widgets/`) | +| `*.py`, `*.js` | no | Scripts (copied to `dist/scripts/`) | + +Skills are auto-discovered: the collection imports `./all?skills` — no per-skill imports needed. + +→ Code in `REFERENCE.md` § Skill. + +### Interaction + +Two flavors: + +- **Template-based** — `prompt.hbs` + `prompt_schema.ts` + `result_schema.ts` + `index.ts` (`InteractionSpec` importing the prompt via `?prompt`). +- **Code-based** — `index.ts` only, with `prompts: [{ role, content, content_type }, …]` inline. Use this for agents/conversations. + +Then export from `interactions//index.ts` as an `InteractionCollection`. + +→ Code in `REFERENCE.md` § Interaction (template-based) and § Interaction (code-based). + +### Content Type + +One file per type in `src/tool-server/types//.ts` (`InCodeTypeSpec`), then a `ContentTypesCollection` in `types//index.ts`. + +Key fields: `name` (snake_case), `object_schema` (JSON Schema with `additionalProperties: false`), `table_layout` (columns for the UI), `is_chunkable`, `strict_mode`. + +→ Code in `REFERENCE.md` § Content Type. + +### Rendering Template + +Folder per template in `src/tool-server/templates///`: + +- `TEMPLATE.md` with YAML frontmatter (`description`, `type: 'document' | 'presentation'`, optional `title`, `tags`) +- Asset files (SVG, LaTeX, PNG) — auto-discovered, copied to `dist/templates/` + +Templates are auto-discovered: the collection imports `./all?templates`. + +→ Code in `REFERENCE.md` § Rendering Template. + +## Collection registration + +Once a collection is exported from `src/tool-server///index.ts`, add it to the array in `src/tool-server//index.ts`: + +```typescript +// src/tool-server/tools/index.ts +import { ExampleTools } from "./examples/index.js"; +import { MyTools } from "./my-collection/index.js"; + +export const tools = [ExampleTools, MyTools]; +``` + +`config.ts` already wires those per-type index files into the server — no further changes needed there. + +Each collection needs an SVG `icon.svg.ts` (default string export). Code in `REFERENCE.md` § Collection registration & icons. + +## Verification + +After creating a resource: + +1. `pnpm build:server` +2. `pnpm start` +3. Check the admin UI at `http://localhost:3000/` — your resource should appear. +4. Or hit the API: `curl http://localhost:3000/api/tools` (or `/skills`, `/interactions`, `/types`, `/templates`). diff --git a/templates/plugin-template/.claude/skills/vertesia-ui/SKILL.md b/templates/plugin-template/.claude/skills/vertesia-ui/SKILL.md index 6d086ea2e..d6f7c8c59 100644 --- a/templates/plugin-template/.claude/skills/vertesia-ui/SKILL.md +++ b/templates/plugin-template/.claude/skills/vertesia-ui/SKILL.md @@ -1,6 +1,6 @@ --- name: vertesia-ui -description: Reference for building UIs with @vertesia/ui. Covers component API (Input, Button, Card, Modal, Tabs, Table), table views with infinite scroll and filters, layout (Sidebar, FullHeightLayout), routing (NestedRouterProvider, useParams, useNavigate), agent conversation (ModernAgentConversation), and styling. Use when creating or modifying React UI pages or components. +description: Reference for building UIs with @vertesia/ui. Covers component API (Input, Button, VModal, VTabs, Table), list/detail tables with infinite scroll, sortable headers, FilterProvider with URL persistence and inline row filters, layout (Sidebar, FullHeightLayout, GenericPageNavHeader), routing (NestedRouterProvider, useParams, useNavigate), agent conversation (ModernAgentConversation), styling, and security. Use when creating or modifying React UI pages or components. --- # Vertesia UI Development @@ -449,100 +449,18 @@ Wraps app in `VertesiaShell` + `RouterProvider`. Mounts `AdminApp` at `/`, plugi ## Security -### XSS Prevention +Apply these rules to any page or component that takes user input or hits the API. Full patterns + code in `references/security.md`. -React escapes JSX content automatically, but be careful with: - -```tsx -// NEVER use dangerouslySetInnerHTML with unsanitized input -

// ❌ - -// If you must render HTML, sanitize first -import DOMPurify from 'dompurify'; -
// ✅ -``` - -### URL Validation - -Validate user-provided URLs before rendering as links: - -```tsx -function isValidUrl(url: string): boolean { - try { - const parsed = new URL(url); - return ['http:', 'https:'].includes(parsed.protocol); - } catch { return false; } -} - -// Only render validated URLs -{isValidUrl(userUrl) && Link} -``` - -### Error Handling - -Never expose internal details in user-facing errors: - -```tsx -try { - await client.objects.retrieve(objectId); -} catch (error) { - console.error('Object retrieval failed:', error); // Log full details - toast({ title: 'Error', description: 'Unable to load data. Please try again.', variant: 'destructive' }); // Generic message -} -``` - -### Secrets - -- Never hardcode API keys or tokens — use environment variables -- Prefix client-side env vars with `VITE_` (they are embedded in the bundle) -- Keep sensitive keys server-side only (tool server code, not UI) -- Never commit `.env` files — use `.env.example` for documentation - -### Form Security - -- Validate inputs before submitting (use Zod or similar for schema validation) -- Validate file uploads: check type, size, and extension before uploading - -```tsx -const MAX_SIZE = 10 * 1024 * 1024; // 10MB -const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'application/pdf']; - -if (file.size > MAX_SIZE) throw new Error('File too large'); -if (!ALLOWED_TYPES.includes(file.type)) throw new Error('Invalid file type'); -``` - -### Client-Side Throttling - -Disable buttons after the first click and re-enable in a `finally` block. This prevents duplicate submissions and gives users clear feedback: - -```tsx -const [isSubmitting, setIsSubmitting] = useState(false); - -async function handleSubmit() { - if (isSubmitting) return; - setIsSubmitting(true); - try { - await client.someApi.doAction(payload); - toast({ title: 'Success', description: 'Item saved' }); - } catch (error) { - console.error('Action failed:', error); - toast({ title: 'Error', description: 'Unable to save. Please try again.', variant: 'destructive' }); - } finally { - setIsSubmitting(false); - } -} - - -``` - -Apply this pattern to **every** button that triggers an async action — form submissions, delete confirmations, API calls, etc. +- Never pass unsanitized HTML to `dangerouslySetInnerHTML`. +- Validate user-provided URLs before rendering as links. +- Catch API errors, log details to console, surface a generic message via `useToast` — never leak internal error text. +- Client-side env vars must use the `VITE_` prefix (they are embedded in the bundle); keep secrets server-side. +- Throttle async button actions with an `isSubmitting` flag cleared in `finally`. ## Development Practices - Extract components from map callbacks when the body has logic or is more than 2-3 lines - Use `` from `@vertesia/ui/core` for dividers instead of `border-t/b` - Debounce expensive search operations -- Always use the client-side throttling pattern (see Security section) for async button actions +- Always throttle async button actions with `isSubmitting` (see `references/security.md`) - Show generic user-facing error messages; log details to console diff --git a/templates/plugin-template/.claude/skills/vertesia-ui/references/security.md b/templates/plugin-template/.claude/skills/vertesia-ui/references/security.md new file mode 100644 index 000000000..d1f9f052a --- /dev/null +++ b/templates/plugin-template/.claude/skills/vertesia-ui/references/security.md @@ -0,0 +1,89 @@ +# Security Practices for `@vertesia/ui` Pages + +Quick rules and the patterns they correspond to. Apply these to any page or component that takes user input or makes API calls. + +## XSS Prevention + +React escapes JSX content automatically. Be careful only with `dangerouslySetInnerHTML`. + +```tsx +// NEVER use dangerouslySetInnerHTML with unsanitized input +
// ❌ + +// If you must render HTML, sanitize first +import DOMPurify from 'dompurify'; +
// ✅ +``` + +## URL Validation + +Validate user-provided URLs before rendering as links: + +```tsx +function isValidUrl(url: string): boolean { + try { + const parsed = new URL(url); + return ['http:', 'https:'].includes(parsed.protocol); + } catch { return false; } +} + +{isValidUrl(userUrl) && Link} +``` + +## Error Handling + +Never expose internal API details to users. Log full details to console; show generic messages with `useToast`. + +```tsx +try { + await client.objects.retrieve(objectId); +} catch (error) { + console.error('Object retrieval failed:', error); // full details + toast({ status: 'error', title: 'Unable to load data. Please try again.' }); +} +``` + +## Secrets + +- Never hardcode API keys or tokens — use environment variables. +- Prefix client-side env vars with `VITE_` (they are embedded in the bundle). +- Keep sensitive keys server-side only (tool server code, not UI). +- Never commit `.env` files — use `.env.example` for documentation. + +## Form Security + +Validate inputs before submitting (Zod or similar). Validate file uploads (type, size, extension): + +```tsx +const MAX_SIZE = 10 * 1024 * 1024; // 10MB +const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'application/pdf']; + +if (file.size > MAX_SIZE) throw new Error('File too large'); +if (!ALLOWED_TYPES.includes(file.type)) throw new Error('Invalid file type'); +``` + +## Client-Side Throttling + +Disable buttons after the first click and re-enable in a `finally` block. Apply to **every** button that triggers an async action — form submissions, deletions, API calls. + +```tsx +const [isSubmitting, setIsSubmitting] = useState(false); + +async function handleSubmit() { + if (isSubmitting) return; + setIsSubmitting(true); + try { + await client.someApi.doAction(payload); + toast({ status: 'success', title: 'Item saved' }); + } catch (error) { + console.error('Action failed:', error); + toast({ status: 'error', title: 'Unable to save. Please try again.' }); + } finally { + setIsSubmitting(false); + } +} + + +``` diff --git a/templates/plugin-template/CLAUDE.md b/templates/plugin-template/CLAUDE.md index ade34d18a..3792bbf68 100644 --- a/templates/plugin-template/CLAUDE.md +++ b/templates/plugin-template/CLAUDE.md @@ -2,6 +2,31 @@ Dual-architecture plugin: **Hono tool server** (backend) + **React UI plugin** (frontend), built and deployed as a single unit. +## Skill Auto-Invocation + +IMPORTANT: You MUST invoke the relevant skill using the Skill tool BEFORE starting implementation for the task types below. Skills contain detailed, up-to-date patterns beyond what this file covers. + +| When the task involves... | INVOKE this skill first | +| ------------------------------------------------------------------------------- | ------------------------------- | +| Reviewing requirement docs, discovery notes, or feature/demo asks | `vertesia-gap-assessment` | +| Understanding plugin architecture, dual build system, or `config.ts` | `vertesia-plugin` | +| Adding tools, skills, interactions, content types, or rendering templates | `vertesia-tool-server-resource` | +| Building or modifying React UI pages or components | `vertesia-ui` | +| Calling the Vertesia client API (objects, workflows, interactions, files) | `vertesia-api` | +| Writing or debugging DSL workflows that call remote activities | `vertesia-dsl-workflow` | +| Seeding the store with realistic demo objects | `vertesia-demo-content` | + +### Typical "build a feature" loop + +1. If the requirement is fuzzy or comes from a discovery doc → `vertesia-gap-assessment` first. +2. Need plugin context (build, layout, deployment) → `vertesia-plugin`. +3. Pick the implementation skill: + - Backend resources (tools/skills/interactions/types/templates) → `vertesia-tool-server-resource` + - UI page or component → `vertesia-ui` + - Multi-step orchestration → `vertesia-dsl-workflow` +4. Add `vertesia-api` whenever the implementation reads or writes the platform. +5. Add `vertesia-demo-content` if you need real seed objects to exercise the feature end-to-end. + ## Build & Dev Commands ```bash @@ -16,64 +41,39 @@ pnpm start # Preview production build (build:server + vite previ ## Dual Build System -| Component | Bundler | Entry | tsconfig | Output | -|-----------|---------|-------|----------|--------| -| Tool Server | Rollup | `src/tool-server/server.ts` | `tsconfig.tool-server.json` | `lib/*.js` | -| UI Plugin | Vite | `src/ui/plugin.tsx` | `tsconfig.ui.json` | `dist/lib/plugin.js` | -| UI App | Vite | `src/ui/main.tsx` | `tsconfig.ui.json` | `dist/ui/` | -| Widgets | Rollup | `skills/**/*.tsx` | `tsconfig.widgets.json` | `dist/widgets/` | +| Component | Bundler | Entry | tsconfig | Output | +|-------------|---------|-----------------------------|-----------------------------|-----------------------| +| Tool Server | Rollup | `src/tool-server/server.ts` | `tsconfig.tool-server.json` | `lib/*.js` | +| UI Plugin | Vite | `src/ui/plugin.tsx` | `tsconfig.ui.json` | `dist/lib/plugin.js` | +| UI App | Vite | `src/ui/main.tsx` | `tsconfig.ui.json` | `dist/ui/` | +| Widgets | Rollup | `skills/**/*.tsx` | `tsconfig.widgets.json` | `dist/widgets/` | + +## Key Files + +| File | Purpose | +|-------------------------------|------------------------------------------------------| +| `src/tool-server/config.ts` | Registers all collections — add new resources here | +| `src/tool-server/settings.ts` | Plugin settings JSON Schema | +| `src/ui/plugin.tsx` | Library entry for the Vertesia host app | +| `src/ui/main.tsx` | Standalone dev entry (VertesiaShell + AdminApp) | +| `src/ui/routes.tsx` | Route definitions (NestedRouterProvider) | +| `src/ui/index.css` | Tailwind CSS 4 entry with shared styles import | -## Code Style +## Plugin-Specific Conventions - ESM with `.js` import extensions in tool-server code: `import { x } from "./foo.js"` - Type-safe definitions: `{} satisfies Tool`, `{} satisfies InCodeTypeSpec`, `{} satisfies InteractionSpec` -- All tool/skill/interaction/type/template collections must be registered in `src/tool-server/config.ts` -- Import hooks (`?skill`, `?skills`, `?prompt`, `?raw`, `?template`, `?templates`) only work in Rollup-compiled tool-server code, not in UI code +- All collections must be registered in `src/tool-server/config.ts` (or its per-type index files) +- Standalone dev requires HTTPS (Firebase auth): +- Set `VITE_APP_NAME` in `.env.app`; use `.env.app.local` for local overrides - Icons are SVG strings exported as default from `.ts` files -## Key Files +## Cross-Cutting Pitfalls -| File | Purpose | -|------|---------| -| `src/tool-server/config.ts` | Registers all collections — add new resources here | -| `src/tool-server/settings.ts` | Plugin settings JSON Schema | -| `src/ui/plugin.tsx` | Library entry for Vertesia host app | -| `src/ui/main.tsx` | Standalone dev entry (VertesiaShell + AdminApp) | -| `src/ui/routes.tsx` | Route definitions (NestedRouterProvider) | -| `src/ui/index.css` | Tailwind CSS 4 entry with shared styles import | - -## UI Development - -- React 19, Tailwind CSS 4, `@vertesia/ui` component library -- Component API reference: `composableai/packages/ui/llms.txt` (also shipped with npm package) -- Use `@vertesia/ui/core` for components, `@vertesia/ui/router` for navigation, `@vertesia/ui/session` for auth -- Standalone dev requires HTTPS (Firebase auth): https://localhost:5173 -- Set `VITE_APP_NAME` in `.env.app`; use `.env.app.local` for local overrides +Fast-path reminders — these bite often enough to flag here even though the relevant skill covers them: + +- **Import hooks are Rollup-only**: `?skill`, `?skills`, `?prompt`, `?raw`, `?template`, `?templates` fail silently or error in Vite UI code. They work only in tool-server code. +- **Must register in `config.ts`**: a collection that isn't wired into `config.ts` (or its per-type index) won't be served. +- **`Input.onChange` takes the value directly** (`onChange={setValue}`), not a React event — `Textarea` uses standard events. -## Development Practices - -- Extract components from map callbacks when the body has logic or is more than 2-3 lines -- Use `flex flex-col gap-{n}` for vertical spacing (not `space-y-*`) -- Debounce expensive search/filter operations -- Guard form submissions with `isSubmitting` state to prevent double-clicks -- Show generic user-facing error messages; log details to console -- Never hardcode secrets or API keys — use environment variables (`VITE_*` prefix for client-side) -- Validate user inputs before passing to API calls -- Never use `dangerouslySetInnerHTML` without sanitization - -## Common Pitfalls - -- **Import hooks are Rollup-only**: `?skill`, `?prompt`, `?raw` imports fail silently or error in Vite UI code -- **Must register in config.ts**: Creating a collection without adding it to `config.ts` means it won't be served -- **Input onChange API**: `@vertesia/ui` Input passes value directly (`onChange={setValue}`), not a React event — Textarea uses standard events -- **listConversations limitations**: Does not return the `input` field — only `topic` is available for labeling conversations; fall back to date/time -- **getRunDetails** for full data: Use `client.store.workflows.getRunDetails(runId, workflowId)` when you need `input` or history - -## Skills - -| Skill | Use when | -|-------|----------| -| `write-tool-server-resource` | Adding new tools, skills, interactions, content types, or templates to the tool server | -| `vertesia-plugin` | Understanding plugin architecture, build system, or configuration | -| `vertesia-api` | Working with the Vertesia client API (objects, workflows, interactions, auth) | -| `vertesia-ui` | Building UI pages and components (routing, layout, styling, agent conversation) | +For full UI patterns (tables, filters, sort, security) see `vertesia-ui`; for tool-server scaffolding conventions see `vertesia-tool-server-resource`. From e9d86f299096259674f47d66dbade2cfcafa200c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Vachette?= <5880528+michaelva@users.noreply.github.com> Date: Fri, 8 May 2026 10:22:31 +0900 Subject: [PATCH 52/75] add agents resources --- .../.agents/skills/vertesia-api/SKILL.md | 260 ++++++++++ .../skills/vertesia-demo-content/SKILL.md | 105 ++++ .../scripts/generate_markdown_seed.py | 207 ++++++++ .../skills/vertesia-dsl-workflow/SKILL.md | 212 ++++++++ .../skills/vertesia-gap-assessment/SKILL.md | 187 +++++++ .../references/sources-and-checks.md | 198 ++++++++ .../skills/vertesia-plugin/REFERENCE.md | 151 ++++++ .../.agents/skills/vertesia-plugin/SKILL.md | 180 +++++++ .../REFERENCE.md | 394 +++++++++++++++ .../vertesia-tool-server-resource/SKILL.md | 109 ++++ .../.agents/skills/vertesia-ui/SKILL.md | 466 ++++++++++++++++++ .../references/generic-table-pattern.md | 437 ++++++++++++++++ .../skills/vertesia-ui/references/security.md | 89 ++++ templates/plugin-template/AGENTS.md | 1 + .../src/ui/ContentObjectsPage.tsx | 452 +++++++++++++++++ .../plugin-template/src/ui/PluginSidebar.tsx | 10 +- templates/plugin-template/src/ui/env.ts | 6 +- templates/plugin-template/src/ui/routes.tsx | 5 + 18 files changed, 3465 insertions(+), 4 deletions(-) create mode 100644 templates/plugin-template/.agents/skills/vertesia-api/SKILL.md create mode 100644 templates/plugin-template/.agents/skills/vertesia-demo-content/SKILL.md create mode 100755 templates/plugin-template/.agents/skills/vertesia-demo-content/scripts/generate_markdown_seed.py create mode 100644 templates/plugin-template/.agents/skills/vertesia-dsl-workflow/SKILL.md create mode 100644 templates/plugin-template/.agents/skills/vertesia-gap-assessment/SKILL.md create mode 100644 templates/plugin-template/.agents/skills/vertesia-gap-assessment/references/sources-and-checks.md create mode 100644 templates/plugin-template/.agents/skills/vertesia-plugin/REFERENCE.md create mode 100644 templates/plugin-template/.agents/skills/vertesia-plugin/SKILL.md create mode 100644 templates/plugin-template/.agents/skills/vertesia-tool-server-resource/REFERENCE.md create mode 100644 templates/plugin-template/.agents/skills/vertesia-tool-server-resource/SKILL.md create mode 100644 templates/plugin-template/.agents/skills/vertesia-ui/SKILL.md create mode 100644 templates/plugin-template/.agents/skills/vertesia-ui/references/generic-table-pattern.md create mode 100644 templates/plugin-template/.agents/skills/vertesia-ui/references/security.md create mode 120000 templates/plugin-template/AGENTS.md create mode 100644 templates/plugin-template/src/ui/ContentObjectsPage.tsx diff --git a/templates/plugin-template/.agents/skills/vertesia-api/SKILL.md b/templates/plugin-template/.agents/skills/vertesia-api/SKILL.md new file mode 100644 index 000000000..68894e3fa --- /dev/null +++ b/templates/plugin-template/.agents/skills/vertesia-api/SKILL.md @@ -0,0 +1,260 @@ +--- +name: vertesia-api +description: Reference for the Vertesia client API (@vertesia/client). Covers available APIs, common operations (objects, workflows, interactions, files), authentication helpers, and query patterns. Use when building tools or UI that call the Vertesia platform. +--- + +# Vertesia Client API + +The `@vertesia/client` package provides a typed client for the Vertesia platform. + +## Getting the Client + +```typescript +// In tool server code (inside tool run()) +const client = await context.getClient(); + +// In UI code (React component) +import { useUserSession } from "@vertesia/ui/session"; +const { client, user, project } = useUserSession(); +``` + +## Available APIs + +### Studio APIs + +| API | Description | +|-----|-------------| +| `client.projects` | Project management | +| `client.environments` | Environment configuration | +| `client.interactions` | Interaction execution and management | +| `client.prompts` | Prompt templates | +| `client.runs` | Run history and analytics | +| `client.account` | Current account operations | +| `client.analytics` | Analytics data | +| `client.apps` | Application management | +| `client.iam` | Identity and access management | +| `client.users` | User management | +| `client.apikeys` | API key management | +| `client.refs` | Reference management | +| `client.commands` | Command execution | + +### Store APIs + +Accessible via `client.store.*` or shortcuts: + +| API | Shortcut | Description | +|-----|----------|-------------| +| `client.store.objects` | `client.objects` | Content object CRUD, search, upload | +| `client.store.files` | `client.files` | File operations | +| `client.store.types` | `client.types` | Content type definitions | +| `client.store.workflows` | `client.workflows` | Workflow management, conversations | +| `client.store.collections` | — | Collection management | +| `client.store.embeddings` | — | Embedding operations | +| `client.store.agents` | — | Agent operations | + +## Object Operations + +```typescript +// Retrieve by ID +const object = await client.objects.retrieve(objectId); + +// List with pagination +const objects = await client.objects.list({ limit: 100, offset: 0 }); + +// Search by text +const results = await client.objects.search({ query: 'search terms' }); + +// Find with MongoDB-style queries +const objects = await client.objects.find({ + where: { status: 'active', type: 'article' }, + limit: 50, +}); + +// Count +const { count } = await client.objects.count({ where: { status: 'active' } }); + +// Upload content +await client.objects.upload(payload); + +// Get download URL +const { url } = await client.objects.getDownloadUrl(fileUri); + +// Analyze document +const analysis = await client.objects.analyze(objectId); +``` + +## Workflow / Conversation Operations + +```typescript +// List conversations for an interaction +const { runs } = await client.store.workflows.listConversations({ + interaction: 'app:my-app:collection:interaction-name', + page_size: 20, +}); +// Returns: { runs: WorkflowRun[], next_page_token?, has_more? } +// WorkflowRun has: run_id, workflow_id, started_at, status, topic +// NOTE: listConversations does NOT return input field — use getRunDetails for that + +// Get full run details (includes input, history) +const run = await client.store.workflows.getRunDetails(runId, workflowId); + +// Search runs with filters +const results = await client.store.workflows.searchRuns({ + interaction: 'interaction-name', + status: 'completed', + page_size: 10, +}); + +// Send signal to active workflow +await client.store.workflows.sendSignal(workflowId, runId, 'signal-name', payload); +``` + +## Interaction Execution + +```typescript +// Synchronous execution (wait for result) +const result = await client.interactions.execute({ + interaction: 'app:my-app:collection:interaction-name', + data: { input: 'some data' }, +}); + +// Asynchronous execution (start and get run IDs) +const result = await client.interactions.executeAsync({ + type: 'conversation', + interaction: 'app:my-app:collection:interaction-name', + interactive: true, + data: { user_prompt: 'Hello' }, +}); +// result: { runId: string, workflowId: string } +``` + +### Interaction naming convention + +Interactions registered by plugins follow this pattern: +``` +app::: +``` + +## Authentication Helpers + +```typescript +// Get raw JWT token +const jwt = await client.getRawJWT(); + +// Get decoded JWT payload +const payload = await client.getDecodedJWT(); + +// Get current project from JWT +const project = await client.getProject(); + +// Get current account from JWT +const account = await client.getAccount(); +``` + +## Client Initialization (standalone) + +When creating the client directly (outside Vertesia host): + +```typescript +import { VertesiaClient } from "@vertesia/client"; + +const client = new VertesiaClient({ + site: 'api.vertesia.io', // or 'api-preview.vertesia.io' + apikey: 'your-api-key', + sessionTags: ['your-session-tag'], +}); + +// Or from an auth token: +const client = await VertesiaClient.fromAuthToken(jwtToken); +``` + +## Query Patterns + +Use MongoDB-style query operators in `find()` and `count()`: + +```typescript +// Equality +{ where: { status: 'active' } } + +// Comparison +{ where: { score: { $gt: 80, $lte: 100 } } } + +// Logical +{ where: { $and: [{ status: 'active' }, { type: 'article' }] } } +{ where: { $or: [{ status: 'draft' }, { status: 'review' }] } } + +// In set +{ where: { category: { $in: ['news', 'blog'] } } } +``` + +**Security:** Never pass user input directly as query operators. Validate and whitelist allowed operators to prevent injection. + +## Security + +### Input Validation + +Always validate user inputs before passing to API calls: + +```typescript +// Validate ID format before querying +if (typeof objectId !== 'string' || !objectId.match(/^[0-9a-f]{24}$/)) { + throw new Error('Invalid object ID'); +} +const object = await client.objects.retrieve(objectId); +``` + +### Query Injection Prevention + +Never construct queries from raw user input: + +```typescript +// ❌ BAD — user could inject operators like $where, $regex +const filter = JSON.parse(userInput); +await client.objects.find({ where: filter }); + +// ✅ GOOD — whitelist allowed fields and values +await client.objects.find({ + where: { status: { $eq: validatedStatus }, type: { $eq: validatedType } } +}); +``` + +### Authorization + +Check authentication state before accessing protected resources: + +```typescript +const { client, user } = useUserSession(); + +// Verify user has access before performing actions +if (!user) { + throw new Error('Not authenticated'); +} +``` + +In tool server code, the SDK handles JWT validation automatically. Access the authenticated client: + +```typescript +async run(payload, context) { + const client = await context.getClient(); + // client is pre-authenticated — org/project scoped from the JWT +} +``` + +### Error Handling + +Never expose internal API details to users: + +```typescript +try { + await client.objects.delete(objectId); +} catch (error) { + console.error('Delete failed:', error); // Log full error server-side + throw new Error('Unable to delete item'); // Generic user-facing message +} +``` + +### Sensitive Data + +- Never log JWT tokens, API keys, or user credentials +- Use `VERTESIA_ALLOWED_ORGS` to restrict tool server access to specific organizations +- Store secrets in environment variables, never in code diff --git a/templates/plugin-template/.agents/skills/vertesia-demo-content/SKILL.md b/templates/plugin-template/.agents/skills/vertesia-demo-content/SKILL.md new file mode 100644 index 000000000..594bbfa05 --- /dev/null +++ b/templates/plugin-template/.agents/skills/vertesia-demo-content/SKILL.md @@ -0,0 +1,105 @@ +--- +name: vertesia-demo-content +description: Generate realistic reusable demo or seed content for Vertesia applications and upload it through the Vertesia CLI, API, or browser session. Use when building Vertesia custom apps, plugins, prototypes, repository workflows, AI extraction workflows, search/filter pages, or demos where the app should use real store objects instead of hardcoded mock arrays; includes checking the active CLI profile and asking before mutating a Vertesia project. +--- + +# Vertesia Demo Content + +Use this skill when a Vertesia app needs real content in the store so upload, search, filters, metadata extraction, workflows, and detail pages can be exercised end to end. + +## Core Rule + +Prefer realistic generated content uploaded to Vertesia over hardcoded UI mock data. Static arrays are acceptable only as temporary empty states, fallback examples, or tests that intentionally avoid network/project mutation. + +## Workflow + +1. Inspect the active target before mutation: + - Run `vertesia profiles show`. + - Identify the active/default profile, account, project, environment, and API base URL. + - Do not print full API tokens in the final answer. + - Ask the user to confirm the target project before uploading, deleting, or updating content. + +2. Confirm the target content type: + - Prefer the actual type id from Vertesia’s type catalog or the app/tool-server package. + - For app-contributed in-code types, do not assume the local type name is the runtime create id. + - Distinguish: + - query/search type name, for example `clm_contract` + - app in-code create id, for example `app::clm:ClmContract` + - If uncertain, inspect available types with CLI, project app-type APIs, and app endpoints before uploading. + +3. Generate useful files: + - Use Markdown for document-like demos unless the user requests DOCX, PDF, JSON, or another format. + - Include domain-specific details that make search, filters, extraction, summaries, and detail views meaningful. + - Include structured headings and bullet metadata so AI extraction has clear signals. + - Save generated files under `/tmp`, `/private/tmp`, or a user-approved fixture path. Do not commit generated seed data unless requested. + +4. Upload through the best available channel: + - Prefer `vertesia content post --type --mime text/markdown --name --path ` when the CLI profile is confirmed and the target type is a stored/system type or the CLI is known to accept the app-defined type. + - Use the browser/plugin session when upload must happen as the signed-in UI user. + - Use direct API when the CLI rejects an app-defined type but the project app-type APIs and runtime create path accept it. + +5. Wire the app to real objects: + - Replace static arrays with `store.objects.search`, `store.objects.list`, or collection search. + - Filter by full text plus metadata fields such as `properties.status`, `properties.owner`, `properties.category`, `properties.risk_level`, or app-specific fields. + - After upload, trigger a refetch or insert the created object into local state. + - For metadata extraction, read object text, run the relevant interaction, then update object properties. + +## App Type Rules + +When seeding app-defined content: + +- Use query/search names such as `clm_contract` for filters and object searches. +- Use the resolved app type code such as `app::clm:ClmContract` for object creation. +- If UI actions create child records, use the same explicit app type codes there too. +- If agent runs or workflow `executeInteraction` calls target app interactions, use the full app interaction id such as `app::clm:ExtractContractMetadata`. + +## CLI Limitation + +`vertesia content post` may validate only against `client.types.list()`, which can exclude app-defined in-code types. + +If the CLI says the type does not exist: + +1. verify the app package exposes the type +2. verify the project app-type APIs list and retrieve it +3. test direct object creation with the resolved app type code +4. use a repo-local uploader script or direct API if the project accepts the type but the CLI still rejects it + +Do not assume CLI rejection means the app type is missing from the project. + +## CLI Pattern + +Upload generated Markdown: + +```bash +vertesia content post /tmp/demo-seed/acme-doc.md \ + --type \ + --mime text/markdown \ + --name "Acme Demo Document" \ + --path /demo +``` + +List uploaded content: + +```bash +vertesia content list /demo +``` + +## Generator Script + +Use `scripts/generate_markdown_seed.py` to generate reusable Markdown seed data: + +```bash +python3 .claude/skills/vertesia-demo-content/scripts/generate_markdown_seed.py \ + --out /tmp/vertesia-demo \ + --domain generic \ + --count 3 +``` + +Supported built-in domains: + +- `generic`: reusable business documents for repository/search/extraction demos. +- `clm`: contract lifecycle documents with dates, values, renewal terms, obligations, and clauses. +- `support`: customer support cases with severity, owner, product, timeline, and resolution notes. +- `policy`: internal policy/procedure documents with owners, effective dates, controls, and exceptions. + +Use `--domain generic` unless the app domain is known. diff --git a/templates/plugin-template/.agents/skills/vertesia-demo-content/scripts/generate_markdown_seed.py b/templates/plugin-template/.agents/skills/vertesia-demo-content/scripts/generate_markdown_seed.py new file mode 100755 index 000000000..c215c30b3 --- /dev/null +++ b/templates/plugin-template/.agents/skills/vertesia-demo-content/scripts/generate_markdown_seed.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""Generate realistic Markdown seed documents for Vertesia demos.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + + +Sample = dict[str, str] + + +SAMPLES: dict[str, list[Sample]] = { + "generic": [ + { + "slug": "acme-business-review", + "title": "Acme Business Review", + "category": "Customer Review", + "owner": "Customer Success", + "status": "In Review", + "date": "2026-05-15", + "summary": "Quarterly account review covering adoption, risks, renewal outlook, and executive follow-ups.", + "details": "Acme expanded usage across three departments. The main open risk is delayed security questionnaire completion before renewal.", + "actions": "Schedule executive sponsor meeting; complete security questionnaire; prepare renewal pricing proposal.", + }, + { + "slug": "northstar-implementation-plan", + "title": "Northstar Implementation Plan", + "category": "Project Plan", + "owner": "Professional Services", + "status": "Active", + "date": "2026-06-01", + "summary": "Implementation plan for a phased rollout with data migration, training, acceptance criteria, and launch support.", + "details": "Phase one covers workspace setup and identity configuration. Phase two covers content migration and automation testing.", + "actions": "Confirm migration sample set; validate SSO; schedule administrator training.", + }, + { + "slug": "globex-risk-memo", + "title": "Globex Risk Review Memo", + "category": "Risk Memo", + "owner": "Operations", + "status": "Escalated", + "date": "2026-05-22", + "summary": "Operational risk memo documenting supplier delay, customer impact, mitigation plan, and approval needs.", + "details": "Supplier lead time increased from four weeks to nine weeks. Customer launch impact is likely unless substitute inventory is approved.", + "actions": "Approve substitute supplier; notify account team; update delivery forecast.", + }, + ], + "clm": [ + { + "slug": "acme-enterprise-saas-agreement", + "title": "Acme Enterprise SaaS Agreement", + "category": "Customer MSA", + "owner": "Sales Ops", + "status": "In Review", + "date": "2026-05-15", + "summary": "Enterprise subscription agreement with negotiated liability, data processing, uptime SLA, and renewal terms.", + "details": "Counterparty: Acme Corp. Value: $425,000 USD. Risk: High. Expiration: 2026-11-30. Renewal notice: 2026-08-01.", + "actions": "Finance review for contract value; legal review for liability carveouts; security review for data processing exhibit.", + }, + { + "slug": "northstar-vendor-services", + "title": "Northstar Vendor Services Agreement", + "category": "Vendor Agreement", + "owner": "Procurement", + "status": "Active", + "date": "2026-02-01", + "summary": "Managed services agreement with quarterly service reviews, payment milestones, and vendor performance obligations.", + "details": "Counterparty: Northstar Services. Value: $98,000 USD. Risk: Medium. Expiration: 2026-07-15. Renewal notice: 2026-04-16.", + "actions": "Track monthly service report; review SLA performance; prepare renewal recommendation.", + }, + { + "slug": "globex-channel-partner-addendum", + "title": "Globex Channel Partner Addendum", + "category": "Partner Amendment", + "owner": "Partner Sales", + "status": "Renewal Pending", + "date": "2026-04-01", + "summary": "Channel amendment with non-standard exclusivity, revenue-share commitments, and accelerated renewal decision timing.", + "details": "Counterparty: Globex. Value: $720,000 USD. Risk: Critical. Expiration: 2026-06-30. Renewal notice: 2026-05-15.", + "actions": "Executive review for exclusivity; legal review for territory language; renewal decision by notice date.", + }, + ], + "support": [ + { + "slug": "acme-login-incident", + "title": "Acme Login Incident", + "category": "Support Case", + "owner": "Support Engineering", + "status": "Open", + "date": "2026-05-03", + "summary": "Priority support case for intermittent login failures affecting Acme administrators.", + "details": "Severity: High. Product area: Authentication. Error rate increased after SSO certificate rotation.", + "actions": "Validate IdP metadata; rotate cached certificate; provide customer incident summary.", + }, + { + "slug": "northstar-export-request", + "title": "Northstar Export Performance Request", + "category": "Support Case", + "owner": "Customer Support", + "status": "Pending Customer", + "date": "2026-05-07", + "summary": "Customer reports slow exports for large document collections during month-end reporting.", + "details": "Severity: Medium. Product area: Reporting. Export size exceeds 50,000 rows.", + "actions": "Request sample export parameters; propose async export workflow; monitor next month-end run.", + }, + { + "slug": "globex-api-rate-limit", + "title": "Globex API Rate Limit Review", + "category": "Support Case", + "owner": "Developer Support", + "status": "Escalated", + "date": "2026-05-10", + "summary": "Integration is hitting API rate limits during nightly synchronization.", + "details": "Severity: High. Product area: API. Current client retries aggressively without backoff.", + "actions": "Share retry guidance; evaluate rate limit increase; review integration logs.", + }, + ], + "policy": [ + { + "slug": "data-retention-policy", + "title": "Data Retention Policy", + "category": "Policy", + "owner": "Compliance", + "status": "Approved", + "date": "2026-01-01", + "summary": "Policy defining retention periods, archival requirements, deletion approvals, and exception handling.", + "details": "Customer records are retained for seven years unless a shorter contractual retention period applies.", + "actions": "Review exceptions quarterly; confirm deletion audit trail; update retention schedule annually.", + }, + { + "slug": "vendor-onboarding-procedure", + "title": "Vendor Onboarding Procedure", + "category": "Procedure", + "owner": "Procurement", + "status": "Active", + "date": "2026-03-01", + "summary": "Procedure for onboarding vendors with security review, contract approval, tax setup, and performance tracking.", + "details": "Critical vendors require security approval before contract execution and annual reassessment.", + "actions": "Collect W-9; complete security questionnaire; create vendor performance record.", + }, + { + "slug": "incident-response-standard", + "title": "Incident Response Standard", + "category": "Standard", + "owner": "Security", + "status": "In Review", + "date": "2026-04-15", + "summary": "Operational standard for detecting, triaging, communicating, and resolving security incidents.", + "details": "Critical incidents require executive notification within one hour and customer notification review within twenty-four hours.", + "actions": "Validate escalation matrix; run tabletop exercise; update postmortem template.", + }, + ], +} + + +def render(sample: Sample, domain: str) -> str: + return f"""# {sample['title']} + +## Metadata + +- Domain: {domain} +- Category: {sample['category']} +- Owner: {sample['owner']} +- Status: {sample['status']} +- Record Date: {sample['date']} + +## Summary + +{sample['summary']} + +## Details + +{sample['details']} + +## Actions + +{sample['actions']} + +## Extraction Hints + +This document is suitable for testing Vertesia upload, full-text search, metadata filters, AI summarization, metadata extraction, workflow routing, and object detail views. +""" + + +def main() -> int: + parser = argparse.ArgumentParser(description="Generate Markdown seed documents for Vertesia demos.") + parser.add_argument("--out", required=True, help="Output directory.") + parser.add_argument("--domain", choices=sorted(SAMPLES), default="generic", help="Built-in sample domain.") + parser.add_argument("--count", type=int, default=3, help="Number of documents to generate.") + args = parser.parse_args() + + out = Path(args.out) + out.mkdir(parents=True, exist_ok=True) + + samples = SAMPLES[args.domain] + count = max(1, min(args.count, len(samples))) + for sample in samples[:count]: + path = out / f"{sample['slug']}.md" + path.write_text(render(sample, args.domain), encoding="utf-8") + print(path) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/templates/plugin-template/.agents/skills/vertesia-dsl-workflow/SKILL.md b/templates/plugin-template/.agents/skills/vertesia-dsl-workflow/SKILL.md new file mode 100644 index 000000000..99b1e2e6f --- /dev/null +++ b/templates/plugin-template/.agents/skills/vertesia-dsl-workflow/SKILL.md @@ -0,0 +1,212 @@ +--- +name: vertesia-dsl-workflow +description: Reference for DSL workflow definitions, remote activities, variable resolution, and conditions. Use when creating or debugging DSL workflows that call remote activities from plugins. +--- + +# DSL Workflows & Remote Activities + +DSL workflows are declarative step-by-step pipelines defined as JSON. They can call built-in activities and remote activities provided by plugins. + +## Key Source Files + +| File | Purpose | +|------|---------| +| `composableai/packages/workflow/src/dsl/dsl-workflow.ts` | DSL engine: variable init, step execution, remote activity calls | +| `composableai/packages/workflow/src/dsl/vars.ts` | `Vars` class: variable storage, resolution (`${varName}`), dotted path access | +| `composableai/packages/workflow/src/dsl/conditions.ts` | `matchCondition()`: operator-based condition matching | +| `composableai/packages/workflow/src/activities/getObjectFromStore.ts` | Built-in activity to load a ContentObject into workflow vars | +| `composableai/packages/tools-sdk/src/ActivityCollection.ts` | `ActivityCollection` class for registering plugin activities | + +## Workflow Definition Structure + +```typescript +await client.workflows.definitions.create({ + name: 'MyWorkflow', + description: 'Description', + debug_mode: true, + steps: [ + { name: 'activityName', type: 'activity', params: { ... } }, + // ... + ], + vars: {}, // initial workflow variables +}); +``` + +## Built-in Activities + +Common built-in activities available in all DSL workflows: + +| Activity | Purpose | Key Params | +|----------|---------|------------| +| `setDocumentStatus` | Set document status | `{ status: 'processing' \| 'completed' \| ... }` | +| `getObjectFromStore` | Load ContentObject into vars | `{ }` (uses objectId automatically) | +| `generateEmbeddings` | Compute embeddings | `{ type: 'text' \| 'properties' }` | + +### `getObjectFromStore` Pattern + +Use `output` to store the loaded object in a named variable, then access nested fields with dotted paths: + +```typescript +{ name: 'getObjectFromStore', type: 'activity', output: 'task' }, +// Now 'task' is in vars — access fields as 'task.properties.my_field' +``` + +## Remote Activities + +Plugin activities registered via `ActivityCollection` are invoked as DSL steps using the naming convention: + +``` +app::: +``` + +Example: `app:bpce-orchestrator:intake:categorize_task` + +App interactions used by `executeInteraction` follow a similar pattern: + +``` +app::: +``` + +Do not assume a bare interaction name like `ExtractContractMetadata` will resolve inside a workflow if the interaction is contributed by an app. + +### Variable Resolution for Remote Activities + +**CRITICAL**: Remote activity params are resolved in a **new `Vars` scope** (see `dsl-workflow.ts:422`). Workflow-level variables like `objectId` are NOT automatically available in remote activity params. + +You must use `import` to bring workflow variables into scope: + +```typescript +// WRONG - ${objectId} will be undefined +{ + name: 'app:my-plugin:collection:my_activity', + type: 'activity', + params: { task_id: '${objectId}' }, +} + +// CORRECT - import brings objectId into the new Vars scope +{ + name: 'app:my-plugin:collection:my_activity', + type: 'activity', + import: ['objectId'], + params: { task_id: '${objectId}' }, + output: 'result', +} +``` + +The `import` field tells the DSL engine to copy the listed variables from the workflow scope into the activity's `Vars` scope via `vars.createImportVars(activity.import)` at `dsl-workflow.ts:378`. + +### How `objectId` is Initialized + +At `dsl-workflow.ts:133`, the DSL engine initializes workflow vars with: +- `objectId` = first object ID from the trigger event (`objectIds[0]`) +- Plus any vars defined in the workflow definition's `vars` field + +## Conditions on Steps + +Use `condition` to skip steps based on workflow variable values. + +### Condition Syntax + +Conditions use operator objects from `conditions.ts`. The `matchCondition()` function iterates the keys of the condition value and looks them up in the `conditionFns` registry. + +**Available operators:** + +| Operator | Description | Example | +|----------|-------------|---------| +| `$eq` | Equals | `{ $eq: 'value' }` | +| `$ne` | Not equals | `{ $ne: 'value' }` | +| `$in` | In array | `{ $in: ['a', 'b'] }` | +| `$nin` | Not in array | `{ $nin: ['a', 'b'] }` | +| `$exists` | Field exists | `{ $exists: true }` | +| `$null` | Is null/undefined | `{ $null: true }` | +| `$gt`, `$gte`, `$lt`, `$lte` | Comparisons | `{ $gt: 50 }` | +| `$regexp` | Regex match | `{ $regexp: '^EPR-' }` | +| `$startsWith`, `$endsWith`, `$contains` | String ops | `{ $contains: 'text' }` | +| `$or` | OR conditions | `{ $or: [{ $eq: 'a' }, { $eq: 'b' }] }` | + +### CRITICAL: Always Use Operator Objects + +**NEVER pass plain values as conditions.** Plain strings/numbers cause `Unknown condition: 0` errors because `matchCondition()` iterates string characters as keys. + +```typescript +// WRONG - causes "Unknown condition: 0" error +condition: { 'task.properties.task_status': 'categorisation' } + +// CORRECT - use operator object +condition: { 'task.properties.task_status': { $eq: 'categorisation' } } +``` + +### Dotted Path Access in Conditions + +Conditions support dotted paths to access nested variables. The `vars.match()` method uses `getValue(path)` which resolves dotted paths like `task.properties.task_status`: + +```typescript +{ + name: 'app:my-plugin:collection:my_activity', + type: 'activity', + condition: { 'task.properties.field_name': { $eq: 'expected_value' } }, + import: ['objectId'], + params: { task_id: '${objectId}' }, +} +``` + +This requires a prior `getObjectFromStore` step with `output: 'task'` to populate the variable. + +## Complete Workflow Example + +A workflow that loads a task, conditionally processes it, computes embeddings, and sets status: + +```typescript +steps: [ + { name: 'setDocumentStatus', type: 'activity', params: { status: 'processing' } }, + { name: 'getObjectFromStore', type: 'activity', output: 'task' }, + { + name: 'app:my-plugin:intake:categorize_task', + type: 'activity', + condition: { 'task.properties.task_status': { $eq: 'categorisation' } }, + import: ['objectId'], + params: { task_id: '${objectId}' }, + output: 'categorize_result', + }, + { + name: 'app:my-plugin:intake:compute_priority', + type: 'activity', + condition: { 'task.properties.task_status': { $eq: 'categorisation' } }, + import: ['objectId'], + params: { task_id: '${objectId}' }, + output: 'priority_result', + }, + { name: 'generateEmbeddings', type: 'activity', params: { type: 'text' } }, + { name: 'generateEmbeddings', type: 'activity', params: { type: 'properties' } }, + { name: 'setDocumentStatus', type: 'activity', params: { status: 'completed' } }, +] +``` + +## Workflow Rules + +Workflow rules trigger workflows based on events. They match on event name and content type: + +```typescript +await client.workflows.rules.create({ + name: 'My Rule', + endpoint: `wf:${workflowDefinitionId}`, + match: { + '$and': [ + { 'event.name': 'create' }, + { 'event.data.type.name': 'my_content_type' }, + ] + }, +}); +``` + +When reusing a standard intake workflow such as `StandardDocumentIntake`, prefer staging custom enrichment on a later `update` event after the generic intake marks the object completed. This avoids two workflows racing on the same `create` event. + +## Common Pitfalls + +1. **`${objectId}` undefined in remote activity params**: Add `import: ['objectId']` to the step +2. **`Unknown condition: 0` error**: Condition value is a plain string, not an operator object — wrap with `{ $eq: ... }` +3. **Condition not matching nested fields**: Ensure a prior `getObjectFromStore` step with `output` set to populate the variable +4. **Activities not found**: Verify the naming convention `app:::` and that the `ActivityCollection` is registered in `config.ts` +5. **App interaction not found from `executeInteraction`**: Use the full app interaction id `app:::`, not a bare interaction name +6. **App child record creation fails with app type not found**: Use the resolved app type code in `client.objects.create`, not `client.types.getTypeByName()` if the type is app-contributed in-code +7. **Custom CLM/business enrichment races standard intake**: move the custom rule off `create` and trigger on a later `update` condition such as `status=completed` diff --git a/templates/plugin-template/.agents/skills/vertesia-gap-assessment/SKILL.md b/templates/plugin-template/.agents/skills/vertesia-gap-assessment/SKILL.md new file mode 100644 index 000000000..f66836cd2 --- /dev/null +++ b/templates/plugin-template/.agents/skills/vertesia-gap-assessment/SKILL.md @@ -0,0 +1,187 @@ +--- +name: vertesia-gap-assessment +description: Assess the gap between arbitrary requirements and existing Vertesia capabilities before proposing implementation work. Use when reviewing requirement documents, discovery notes, demo asks, or feature requests for a Vertesia app, plugin, workflow, or agent so you can separate native platform support, installed project capabilities, current app implementation, and true custom work. +--- + +# Vertesia Gap Assessment + +Use this skill before proposing implementation plans for a Vertesia solution. The goal is to avoid inventing custom work too early and to ground recommendations in four sources of truth: + +1. live project state +2. official Vertesia docs +3. current codebase +4. related local examples + +Read `references/sources-and-checks.md` at the start of the assessment. + +## What To Produce + +Produce a compact assessment with these sections: + +- Requirement summary +- Existing Vertesia capabilities relevant to the ask +- Existing project or app assets that can be reused +- Gap matrix: + - requirement + - native support + - project support + - app support + - custom work needed +- Recommended implementation path +- Risks or unknowns requiring verification + +Keep the distinction explicit between: + +- what Vertesia supports in general +- what the target project has installed or configured +- what the current app or plugin already uses + +## Workflow + +### 1. Normalize the requirements + +Extract the requested outcomes and group them into a short capability list. Use whatever groups fit the ask, but these are the default buckets: + +- intake +- repository and search +- workflow and approvals +- lifecycle and reminders +- reporting and analytics +- integrations +- assistant and agent behavior +- mobile or responsive UX +- admin and settings + +Do not jump to architecture until the requirement list is normalized. + +### 2. Check sources in the right order + +#### A. Live project state first + +Use the CLI or API to inspect what is actually present in the target Vertesia project: + +- active CLI profile +- installed workflow definitions +- installed workflow rules +- installed apps +- interactions available in the current project +- content objects or uploaded demo data when relevant + +If a command fails due to expired auth, refresh the profile and retry. + +If you inspect profiles with `vertesia profiles show`, do not echo API keys or tokens back to the user. Summarize only the safe fields you need. + +When the question involves app-defined types, interactions, templates, or remote activities, verify the exact runtime identifiers through the project APIs or the app package, not only through local code. App resources often have two useful names: + +- a local/query name used in search filters, such as `clm_contract` +- an app in-code identifier used in create/execute paths, such as `app::clm:ClmContract` + +#### B. Official docs second + +Use the Vertesia docs to determine platform-native capabilities before proposing custom code. The high-value pages are: + +- workflow activities catalog +- agent built-in tools +- agent runner overview +- plugin/custom app docs +- CLI reference + +Prefer standard workflows, built-in workflow activities, and built-in agent tools when they cover the requirement. + +#### C. Current codebase third + +Inspect what the current app already implements: + +- content types +- interactions +- tools and activities +- workflow specs +- UI routes and pages +- local skills and project docs + +For UI requirements, inspect both the local UI code and the available `@vertesia/ui` primitives before proposing custom components. A gap in the current app is not automatically a gap in the shared UI library. + +Do not confuse "possible in Vertesia" with "already wired into this app". + +#### D. Related local examples fourth + +Look for reusable patterns in: + +- nearby plugin repos +- project-local skills +- installed standard workflows in the target project +- prior implementations of similar flows + +Treat installed standard workflows as real reuse candidates, not as theoretical examples. + +### 3. Classify every requirement + +For each requirement, classify it as one of: + +- already implemented +- available natively in Vertesia +- partially covered and should be composed from native pieces +- requires custom plugin or app code +- requires external integration or project configuration + +Use the most conservative label that is still accurate. + +### 4. Recommend the right architectural home + +Use these defaults unless the evidence points elsewhere: + +- deterministic intake and side effects -> DSL workflow +- structured extraction -> interaction executed by workflow +- open-ended review, search, explanation, analysis -> agent +- standard repository operations -> built-in workflow activities or built-in agent tools +- domain-specific routing, linked-record creation, adapter logic -> custom plugin code only if native pieces do not cover it +- UX-only visualization or dashboards -> UI composition +- third-party system access -> integration or adapter layer + +For list/detail UI requirements, also decide where state must live. If users are expected to go from a table to a detail view and back without losing context, page-local state is usually the wrong recommendation. + +Do not assume an agent is the best orchestration model. Check built-in workflow activities first. + +Do not invent custom activities too early. Check the workflow activities catalog first. + +### 5. Write the gap matrix + +For each normalized requirement, capture: + +- requirement +- evidence or source +- current support status +- recommended implementation approach +- custom work still needed +- open unknowns + +Be explicit when the right answer is hybrid, for example: + +- standard workflow + custom enrichment +- built-in tools + custom UI +- deterministic workflow + agent assistant + +## Decision Rules + +- Docs are not enough. Verify installed definitions and project state with the CLI. +- Installed project capabilities are not the same as code already used by this app. +- Existing standard workflows should be inspected before creating new ones. +- Prefer reuse over replacement when a standard workflow covers the generic part and custom code only needs to handle domain enrichment. +- If a requirement depends on configuration, credentials, or app installation settings, call that out separately from code work. +- If standard intake is already installed, prefer adding domain enrichment after the standard workflow completes rather than starting a second workflow on the same `create` event and letting them race. +- CLI support and project runtime support are not the same thing. A CLI command may reject an app-defined type even when direct project APIs and the app runtime accept it. +- For app-defined interactions in workflows, `executeInteraction` typically needs the full app interaction id, not a bare interaction name. +- For app-defined child records created from custom activities or UI actions, prefer the resolved app type code used by the runtime create path. +- For UI work, classify "needs custom UI code" only after checking whether the shared `@vertesia/ui` library already provides the needed primitive. +- For list/detail UI work, inspect the route boundary and the persistence behavior of shared filter components. If `FilterProvider` or URL-backed filters are involved and state also survives navigation, plan a normalization or dedupe step so filter restoration does not create duplicates. +- For sortable, facet-driven repository tables, prefer one backend search path that owns rows, sort, and facets instead of mixing a simpler find path with a richer search path. + +## Output Style + +Keep the assessment compact and implementation-oriented. + +Prefer short grouped bullets and a small matrix over long prose. + +When useful, summarize the recommendation in one sentence: + +`Use for the generic path, then add for the domain-specific gap.` diff --git a/templates/plugin-template/.agents/skills/vertesia-gap-assessment/references/sources-and-checks.md b/templates/plugin-template/.agents/skills/vertesia-gap-assessment/references/sources-and-checks.md new file mode 100644 index 000000000..aa7dac324 --- /dev/null +++ b/templates/plugin-template/.agents/skills/vertesia-gap-assessment/references/sources-and-checks.md @@ -0,0 +1,198 @@ +# Sources And Checks + +Use this file as the concrete checklist when assessing requirements against Vertesia capabilities. + +## 1. Live Project State + +Check project reality before reading docs deeply. + +### CLI commands verified in this environment + +```bash +vertesia profiles show +vertesia profiles refresh +vertesia workflows definitions list +vertesia workflows definitions get +vertesia workflows rules list +vertesia workflows rules get +vertesia apps list-installed +vertesia apps get-installation +vertesia interactions +vertesia content list +vertesia content get +``` + +### Notes + +- `vertesia profiles show` includes sensitive tokens. Never repeat them in user-facing output. +- If workflow or app queries fail with `401 Token expired`, run `vertesia profiles refresh` and retry. +- After auth refresh, verify the existing dev server port, public tunnel, and installed app manifest endpoint before starting replacements. +- Installed workflows matter. In this environment, inspecting installed definitions surfaced reusable system workflows such as `StandardDocumentIntake`. + +### Questions to answer + +- Which profile and project are active? +- Which workflow definitions are installed? +- Which workflow rules are installed? +- Which apps are installed in the project? +- Which interactions are already available? +- Is there existing content/demo data that changes the implementation plan? + +### Resource identity checks + +For app-defined resources, verify the exact ids used by the runtime create/execute paths. + +- Confirm the installed app manifest endpoint is reachable and points to the expected package URL. +- For local development against a cloud project, verify the tunnel URL and the manifest `endpoint` before debugging missing app resources. +- List app types from the project, not just from local code. +- Check the direct app-type detail endpoint, not only the listing endpoint. +- Distinguish: + - query/search type name, for example `clm_contract` + - app in-code type id, for example `app::clm:ClmContract` + - app interaction id, for example `app::clm:ExtractContractMetadata` + +Do not assume the lowercase local type name is also the correct runtime create id. + +### CLI vs API checks + +- `vertesia content post` may validate only against stored/system types and reject app-defined types. +- If the CLI rejects an app-defined type, verify the project app-type APIs before assuming the app is broken. +- When the runtime accepts the app-defined type but the CLI does not, use a direct API uploader or an app-side upload flow. + +## 2. Official Vertesia Docs + +Use docs to determine what the platform supports natively. + +### Priority docs + +- Workflow activities catalog +- Agent built-in tools +- Agent runner overview +- Plugin/custom app docs +- CLI docs + +### What to look for + +- Standard workflow activities that replace custom orchestration code +- Built-in agent tools that replace custom repository/search wrappers +- Existing platform patterns for workflows, agents, search, scheduling, updates, and integrations + +### Reusable lessons + +- Built-in workflow activities often already cover extraction, interaction execution, document updates, progress messages, and embeddings. +- Built-in agent tools often already cover search, fetch, document CRUD, collection management, and optional workflow scheduling. +- Standard intake workflows often cover generic text extraction, property generation, and embeddings. Domain-specific enrichment should usually be staged after standard intake completes. + +## 3. Current Codebase + +Inspect the local app/plugin to see what is already implemented. + +### Default paths to inspect + +```text +src/tool-server/types/ +src/tool-server/interactions/ +src/tool-server/tools/ +src/tool-server/activities/ +src/tool-server/config.ts +src/ui/routes.tsx +src/ui/pages.tsx +workflow-specs/ +.claude/skills/ +``` + +### Questions to answer + +- Which content types already exist? +- Which interactions already exist? +- Which tools or remote activities already exist? +- Are there workflow specs in the repo, or only installed workflows in the project? +- Which UI pages already expose the behavior? +- Does the UI use the same identifiers for query/search and create/execute paths, or are those concerns currently conflated? +- For list/detail UX, where does the list state actually live relative to the route boundary? +- If filters are URL-backed, can the current state model remount and re-append the same filters on back-navigation? + +## 4. Related Examples + +Use nearby examples to reduce custom work. + +### Good sources + +- neighboring plugin repos +- project-local skills +- installed standard workflows +- local implementation examples that use the same Vertesia primitives + +### Example reuse patterns + +- standard workflow + custom post-processing +- built-in interaction execution + custom linked-record persistence +- built-in agent tools + custom assistant prompt +- standard document intake + post-intake enrichment rule on `update` when `status=completed` + +## 5. Classification Framework + +For each requirement, classify it as: + +- already implemented +- available natively in Vertesia +- partially covered and composable from native pieces +- requires custom plugin/app code +- requires external integration or project configuration + +Also record three support layers separately: + +- native platform support +- installed project support +- current app support + +## 6. Architecture Heuristics + +Use these defaults unless evidence shows a better fit: + +- deterministic intake, updates, retries, audit-friendly side effects -> workflow +- structured extraction with schema output -> interaction executed by workflow +- exploratory review, explanation, search assistance -> agent +- standard repository operations -> built-in workflow activities or built-in agent tools +- domain-specific routing and child-record creation -> custom plugin logic if native pieces stop short +- scheduling and outbound communication -> verify native support and project configuration before proposing custom code +- list/detail UX with preserved context -> lift state above the route boundary, persist scroll explicitly, and normalize URL-restored filters + +## 7. Debugging Heuristics + +When a create or execute path fails with "app type not found" or "interaction not found": + +1. verify the installed app manifest points to the correct `/api/package` endpoint +2. verify the public tunnel or deployment URL is reachable and returns the expected app package +3. verify the app package endpoint exposes the resource +4. verify the project app-resource listing endpoint sees it +5. verify the project app-resource detail endpoint resolves it +6. compare the id shape used in the failing payload with the id shape returned by the detail endpoint +7. test direct API create/execute before blaming the app logic + +When debugging locally against a cloud project, app-manifest and tunnel issues are often mistaken for type or interaction bugs. + +When back-navigation in a list/detail UI behaves badly: + +1. check whether filters, sort, and search live inside the list page component +2. check whether a shared filter component also restores from the URL on mount +3. dedupe or normalize filter writes if both persisted React state and URL restoration are active +4. persist scroll position in history state and restore it after the list layout is ready +5. if hover-reveal buttons are invisible, verify Tailwind variant classes are literal strings and not dynamically assembled template fragments + +When auth expires mid-debugging: + +1. refresh auth +2. verify the existing local dev server still responds +3. verify the current tunnel host still resolves +4. verify the installed app manifest still points to that live tunnel +5. only create a new dev server or tunnel if the current pair is invalid + +Do not let multiple quick tunnels and drifting local ports accumulate during one debugging session. + +When two workflows act on the same object: + +1. check installed rules, not just repo workflow specs +2. confirm which event each rule matches +3. avoid `create`-time races when a standard intake workflow already exists +4. move custom enrichment to a later `update` condition if the generic workflow should finish first diff --git a/templates/plugin-template/.agents/skills/vertesia-plugin/REFERENCE.md b/templates/plugin-template/.agents/skills/vertesia-plugin/REFERENCE.md new file mode 100644 index 000000000..79ea6b7d4 --- /dev/null +++ b/templates/plugin-template/.agents/skills/vertesia-plugin/REFERENCE.md @@ -0,0 +1,151 @@ +# Vertesia Plugin Reference + +Additional details for the plugin architecture. Referenced from SKILL.md. + +## Table of Contents + +- [Admin UI](#admin-ui) +- [CSS Customization](#css-customization) +- [Deployment](#deployment) + +For resource creation code examples (tools, skills, interactions, types, templates), use the **vertesia-tool-server-resource** skill. + +--- + +## Admin UI + +The admin UI (`@vertesia/tools-admin-ui`) provides a browsable interface for all plugin resources. It is mounted alongside the plugin app in dev mode. + +### Integration in `main.tsx` + +```typescript +import { AdminApp } from '@vertesia/tools-admin-ui' + +const routes: Route[] = [ + { path: "*", Component: AdminApp }, // Admin UI at / + { path: "app/*", Component: AppWrapper }, // Plugin app at /app/ +] +``` + +### API endpoints fetched + +| Endpoint | Data | +|----------|------| +| `GET /api` | Server info (name, version, endpoints) | +| `GET /api/interactions` | Interaction collections and refs | +| `GET /api/tools` | Tool collections and definitions | +| `GET /api/skills` | Skill collections (exposed as tools) | +| `GET /api/types` | Content type collections and schemas | +| `GET /api/templates` | Template collections and refs | +| `GET /api/package?scope=widgets` | Widget info per skill collection | + +### Routes + +| Route | Shows | +|-------|-------| +| `/` | Collection cards grouped by type, or search results | +| `/tools/:collection` | Tool definitions with input schemas | +| `/skills/:collection` | Skill list with widgets summary | +| `/skills/:collection/:name` | Full skill: widgets, scripts, instructions, schema | +| `/interactions/:collection` | Interaction list | +| `/interactions/:collection/:name` | Prompts, result schema, agent runner flags | +| `/types/:collection` | Content type list | +| `/types/:collection/:name` | Object schema, table layout, flags | +| `/templates/:collection` | Template list | +| `/templates/:collection/:name` | Instructions, assets, type | + +### Standalone development + +```bash +cd composableai/packages/tools-admin-ui +cp .env.local.example .env.local # Set VITE_API_BASE_URL to your running tool server +pnpm dev # Vite dev server on http://localhost:5174 +``` + +--- + +## CSS Customization + +Override CSS custom properties **after** the shared `@vertesia/ui` import in `index.css`: + +```css +@layer base { + :root { + --primary: oklch(55% 0.2 145); /* light mode */ + --primary-background: oklch(97% 0.02 145); + } + .dark { + --primary: oklch(75% 0.18 145); /* dark mode */ + --primary-background: oklch(75% 0.18 145 / 0.2); + } +} +``` + +Available tokens: `--primary`, `--success`, `--attention`, `--destructive`, `--done`, `--info`, `--muted` (each with a `-background` variant), plus `--background`, `--foreground`, `--card-*`, `--sidebar-*`, `--topnav-*`, `--border`, `--input`, `--ring`. See `@vertesia/ui/src/css/color.css` for all values. + +--- + +## Deployment + +### Vercel (Primary) + +The `vercel.json` routes `/api/*` to the serverless function in `api/index.js`. Static files are served from `dist/`. + +```bash +pnpm build && vercel deploy +``` + +### Node.js / Docker + +```bash +pnpm build && pnpm start # Runs on port 3000 (or PORT env var) +``` + +The Node server (`server-node.ts`) serves static files from `dist/` via `@hono/node-server/serve-static`. + +### Tunnel-Based Dev Testing + +When testing a local plugin against a cloud Vertesia project: + +1. start the local HTTPS dev server +2. expose it publicly with a tunnel, for example: + +```bash +npx cloudflared tunnel --url https://localhost:5174 --no-tls-verify +``` + +3. update the installed app manifest so `endpoint` points to: + +```text +https:///api/package +``` + +4. verify: + +- the app manifest now shows the tunnel endpoint +- `GET /api/package?scope=types,interactions,activities` returns the expected app package + +If the project cannot resolve app-defined resources, treat tunnel reachability and manifest endpoint correctness as first-line checks. + +### Auth Expiry Discipline + +When the CLI or project auth expires during tunnel-based testing: + +1. refresh auth first +2. verify whether the current local dev server is still alive on its existing port +3. verify whether the current tunnel host is still reachable +4. verify whether the installed app manifest still points to that live tunnel +5. only create a new tunnel if the current one is actually dead +6. if a new tunnel is created, update the installed app manifest immediately + +Avoid creating a fresh `pnpm dev` process and a fresh quick tunnel by reflex. That leaves multiple ports and stale manifest endpoints in play, which commonly shows up as `Failed to fetch type` or `interaction not found` even though the local app code is fine. + +### Organization Access Restriction + +Set `VERTESIA_ALLOWED_ORGS` to restrict to specific orgs: + +```bash +VERTESIA_ALLOWED_ORGS=org_abc123,org_def456 +``` + +Enforced by `@vertesia/tools-sdk`'s `authorize()` middleware — no code changes needed. Requests from unlisted orgs get `403 Forbidden`. diff --git a/templates/plugin-template/.agents/skills/vertesia-plugin/SKILL.md b/templates/plugin-template/.agents/skills/vertesia-plugin/SKILL.md new file mode 100644 index 000000000..4b19a216a --- /dev/null +++ b/templates/plugin-template/.agents/skills/vertesia-plugin/SKILL.md @@ -0,0 +1,180 @@ +--- +name: vertesia-plugin +description: Reference for plugin architecture, dual build system, import hooks, and deployment. Use when understanding plugin structure or build configuration. For creating resources use vertesia-tool-server-resource; for UI use vertesia-ui; for client API use vertesia-api. +--- + +# Vertesia Plugin Development + +This project is a Vertesia plugin with a dual architecture: a **tool server** (Hono backend) and a **UI plugin** (React frontend), built and deployed as a single unit. + +The `src/tool-server/tools/examples/`, `src/tool-server/skills/examples/`, `src/tool-server/interactions/examples/`, `src/tool-server/types/examples/`, and `src/tool-server/templates/examples/` directories contain starter code demonstrating each resource type. Use them as reference, then replace with your own implementations. + +For extended documentation see `README-tools.md` (tool server), `README-ui.md` (UI plugin), and `TESTING-tools.md` (testing guide) at the project root. + +For full code examples of all resource types, see REFERENCE.md. + +## Project Structure + +``` +src/ + tool-server/ # Backend (Hono) - compiled by Rollup + server.ts # Hono server entry (createToolServer from @vertesia/tools-sdk) + server-node.ts # Node.js HTTP adapter for local dev + config.ts # Server configuration - registers all collections here + settings.ts # JSON Schema for plugin settings + tools/ # Tool collections + / + index.ts # Exports ToolCollection with tool list + / + index.ts # Tool definition (satisfies Tool) + schema.ts # Input JSON Schema + TypeScript params interface + .ts # Implementation logic + skills/ # Skill collections + / + index.ts # Exports SkillCollection (uses ?skills auto-discovery) + / + SKILL.md # Skill definition (YAML frontmatter + markdown body) + properties.ts # Optional: runtime properties (isEnabled function) + *.tsx # Optional: widgets (compiled to dist/widgets/) + *.py, *.js # Optional: scripts (copied to dist/scripts/) + interactions/ # Interaction collections + / + index.ts # Exports InteractionCollection + / + index.ts # InteractionSpec with prompts array + prompt.hbs # Handlebars prompt template (imported with ?prompt) + prompt_schema.ts + result_schema.ts + types/ # Content type collections + / + index.ts # Exports ContentTypesCollection + .ts # InCodeTypeSpec with object_schema + table_layout + templates/ # Template collections (for document/presentation generation) + / + index.ts # Exports RenderingTemplateCollection (uses ?templates auto-discovery) + / + TEMPLATE.md # Template definition (YAML frontmatter + markdown body) + *.svg, *.latex, *.png # Asset files (auto-discovered, copied to dist/templates/) + mcp/ # MCP provider integrations + index.ts # Exports MCPProviderConfig[] + ui/ # Frontend (React) - compiled by Vite + plugin.tsx # Library entry - default export component for Vertesia host + main.tsx # Standalone app entry for dev mode + app.tsx # Main App component + routes.tsx # Route definitions (NestedRouterProvider) + pages.tsx # Page components + env.ts # Environment config (VITE_APP_NAME) + assets.ts # Asset URL resolution (plugin mode vs dev mode) + index.css # Tailwind CSS 4 with @source directives +api/ + index.js # Vercel serverless adapter (forwards to Hono fetch) +dist/ # Build outputs +lib/ # Compiled server JS (ESM) +``` + +## Build System + +Two independent build pipelines: + +| Build | Tool | Entry | Output | Config Files | +|-------|------|-------|--------|--------------| +| Tool Server | Rollup | `src/tool-server/server.ts` | `lib/*.js` (ESM) | `rollup.config.js`, `tsconfig.tool-server.json` | +| UI Plugin (library) | Vite | `src/ui/plugin.tsx` | `dist/lib/plugin.js` | `vite.config.ts --mode lib`, `tsconfig.ui.json` | +| UI App (standalone) | Vite | `src/ui/main.tsx` | `dist/ui/` | `vite.config.ts --mode app`, `tsconfig.ui.json` | +| Skill Widgets | Rollup (via build-tools) | `skills/**/*.tsx` | `dist/widgets/*.js` | `tsconfig.widgets.json` | + +### Commands + +```bash +pnpm build # Full build: server + UI (lib and app) +pnpm build:server # Rollup server only +pnpm dev # Build server + start on port 3000 +pnpm dev:ui # Vite dev server with HMR (https://localhost:5173) +pnpm build:ui:lib # Plugin library build (dist/lib/plugin.js) +pnpm build:ui:app # Standalone app build (dist/ui/) +pnpm start # Run compiled server +pnpm start:watch # Run with --watch (auto-restart on lib/ changes) +pnpm start:debug # Run with --inspect for Node debugger +``` + +## Import Hooks (@vertesia/build-tools) + +These Rollup import transformations only work in `src/tool-server/` code: + +| Import | Produces | +|--------|----------| +| `import x from './my-skill/SKILL.md'` | `SkillDefinition` object (convention-based) | +| `import x from './definition.md?skill'` | `SkillDefinition` object (query-based) | +| `import x from './all?skills'` | `SkillDefinition[]` (auto-discovers subdirs with SKILL.md) | +| `import x from './my-template/TEMPLATE.md'` | `RenderingTemplateDefinition` object | +| `import x from './all?templates'` | `RenderingTemplateDefinition[]` (auto-discovers TEMPLATE.md) | +| `import x from './prompt.hbs?prompt'` | `PromptDefinition { role, content, content_type, schema? }` | +| `import x from './file.html?raw'` | Raw string content | + +## Creating Resources + +To create tools, skills, interactions, content types, or templates, use the **vertesia-tool-server-resource** skill. It provides step-by-step scaffolding with full code examples. + +Each resource follows the same pattern: create files → export from collection → register in `config.ts`. + +## UI Plugin + +For UI component APIs, routing, layout, styling, and agent conversation patterns, use the **vertesia-ui** skill. + +When the task includes UI work, do not stop at "it renders". The UI pass should start with a `@vertesia/ui` component inventory and end with a conformance check for duplicated primitives such as raw tables, native selects, local page headers, and inline styles. + +Key entry points: +- `src/ui/plugin.tsx` — Library entry for Vertesia host (exports default component receiving `{ slot }`) +- `src/ui/main.tsx` — Standalone dev entry (VertesiaShell + AdminApp at `/`, plugin at `/app/`) +- `src/ui/routes.tsx` — Route definitions +- `src/ui/assets.ts` — `useAsset(path)` for URLs relative to the plugin bundle + +## Authentication + +Tool endpoints receive JWT tokens via `Authorization: Bearer {token}`. The SDK validates automatically. Access the client via `const client = await context.getClient()` in tool `run()`. For full client API reference, use the **vertesia-api** skill. + +For organization access restriction and deployment details, see REFERENCE.md. + +## Local Runtime Exposure + +For project-connected testing, the local plugin server is not enough by itself. The installed Vertesia app manifest must point to a reachable package endpoint. + +Use this sequence: + +1. run the local HTTPS dev server +2. expose it with a tunnel such as Cloudflare Tunnel +3. update the installed app manifest `endpoint` to `/api/package` +4. verify the app package from the public URL before debugging project-side failures + +If the project does not see updated types, interactions, or activities, check the manifest endpoint before changing app code. + +When auth expires during this flow: + +1. refresh auth first +2. verify the currently running dev server port still works +3. verify the current public tunnel still resolves +4. verify the installed app manifest still points to that live tunnel +5. only then decide whether a new server or tunnel is needed + +Do not keep spawning new local ports and quick tunnels after an auth failure. The project can easily end up pointing at a dead tunnel even while the local app appears healthy. + +## Key Dependencies + +| Package | Role | +|---------|------| +| `@vertesia/tools-sdk` | Tool server framework: `createToolServer`, `ToolCollection`, `SkillCollection`, auth | +| `@vertesia/tools-admin-ui` | Admin UI: browsable interface for all plugin resources | +| `@vertesia/build-tools` | Rollup import plugins: `?skill`, `?skills`, `?template`, `?templates`, `?prompt`, `?raw` | +| `@vertesia/plugin-builder` | Vite plugin for UI library builds (CSS extraction/injection) | +| `@vertesia/client` | Vertesia API client for tool implementations | +| `@vertesia/common` | Shared types: `InteractionSpec`, `InCodeTypeSpec`, etc. | +| `@vertesia/ui` | UI component library: `core`, `features`, `router`, `layout`, `session`, `shell` | +| `hono` | Lightweight web framework for the tool server | + +## Code Conventions + +- ESM with `.js` import extensions: `import { x } from "./foo.js"` +- Type-safe definitions with inference: `{} satisfies Tool`, `{} satisfies InCodeTypeSpec` +- Icons are SVG strings exported as default from `.ts` files +- All collections must be registered in `src/tool-server/config.ts` +- Skills use YAML frontmatter in `SKILL.md`; templates in `TEMPLATE.md`; prompts in `.hbs`/`.jst`/`.md` with `?prompt` diff --git a/templates/plugin-template/.agents/skills/vertesia-tool-server-resource/REFERENCE.md b/templates/plugin-template/.agents/skills/vertesia-tool-server-resource/REFERENCE.md new file mode 100644 index 000000000..c8491c89a --- /dev/null +++ b/templates/plugin-template/.agents/skills/vertesia-tool-server-resource/REFERENCE.md @@ -0,0 +1,394 @@ +# Write Tool Server Resource — Code Reference + +Full code examples for each resource type. SKILL.md has the workflow and decision-points; this file has the templates you copy from. + +## Table of Contents + +- [Tool](#tool) +- [Skill](#skill) +- [Interaction (template-based)](#interaction-template-based) +- [Interaction (code-based)](#interaction-code-based) +- [Content Type](#content-type) +- [Rendering Template](#rendering-template) +- [Collection registration & icons](#collection-registration--icons) + +--- + +## Tool + +### `schema.ts` + +```typescript +import { JSONSchema } from "@llumiverse/common"; + +export interface MyToolParams { + query: string; + limit?: number; +} + +export const Schema = { + type: "object", + properties: { + query: { type: "string", description: "Search query" }, + limit: { type: "number", description: "Max results" } + }, + required: ["query"] +} satisfies JSONSchema; +``` + +### `.ts` + +```typescript +import { ToolExecutionContext, ToolExecutionPayload } from "@vertesia/tools-sdk"; +import { ToolResultContent } from "@vertesia/common"; +import { type MyToolParams } from "./schema.js"; + +export async function myToolRun( + payload: ToolExecutionPayload, + context: ToolExecutionContext +): Promise { + try { + const { query, limit } = payload.tool_use.tool_input!; + const client = await context.getClient(); + const results = await client.store.objects.find({ where: { name: query }, limit }); + + return { is_error: false, content: JSON.stringify(results) }; + } catch (error) { + return { + is_error: true, + content: `Error: ${error instanceof Error ? error.message : 'Unknown error'}` + }; + } +} +``` + +### `index.ts` (tool definition) + +```typescript +import { Tool } from "@vertesia/tools-sdk"; +import { myToolRun } from "./my-impl.js"; +import { MyToolParams, Schema } from "./schema.js"; + +export const MyTool = { + name: "my_tool", + description: "Description of what this tool does", + input_schema: Schema, + run: myToolRun +} satisfies Tool; +``` + +### Collection (`tools//index.ts`) + +```typescript +import { ToolCollection } from "@vertesia/tools-sdk"; +import { MyTool } from "./my-tool/index.js"; +import icon from "./icon.svg.js"; + +export const MyTools = new ToolCollection({ + name: "my-collection", + title: "My Tools", + description: "Description of this collection", + icon, + tools: [MyTool] +}); +``` + +--- + +## Skill + +### `SKILL.md` + +```markdown +--- +name: my-skill +title: My Skill +description: What this skill does and when to use it +keywords: [keyword1, keyword2, keyword3] +tools: [tool-to-enable] +--- + +# Skill Instructions + +Instructions for the AI agent when this skill is active. + +## What to do + +Describe the behavior, output format, and constraints. +``` + +**Frontmatter fields:** + +- `name` (required): snake_case identifier +- `description` (required): what the skill does +- `title`: display name +- `keywords`: trigger auto-activation when matched +- `tools`: related tools to unlock when skill is active +- `language` / `packages`: for code-execution skills +- `widgets`: UI widgets to render + +### Optional `properties.ts` + +```typescript +import { SkillDefinition, ToolUseContext } from "@vertesia/tools-sdk"; + +export default { + isEnabled(_context: ToolUseContext) { + // Return false to hide this skill based on context/config + return true; + } +} satisfies Partial; +``` + +### Collection auto-discovery + +```typescript +// skills//index.ts +import { SkillCollection } from "@vertesia/tools-sdk"; +import skills from "./all?skills"; + +export const MySkills = new SkillCollection({ + name: "my-collection", + title: "My Skills", + description: "Description of this skill collection", + skills // Auto-discovers all subdirs with SKILL.md +}); +``` + +--- + +## Interaction (template-based) + +### `prompt.hbs` + +```handlebars +{{!-- prompt.hbs --}} +--- +role: user +content_type: handlebars +schema: ./prompt_schema.ts +--- +Analyze the following content: {{input}} +Please provide a {{format}} summary. +``` + +### `prompt_schema.ts` + +```typescript +import { JSONSchema } from "@llumiverse/common"; + +export default { + type: "object", + properties: { + input: { type: "string", description: "Content to analyze" }, + format: { type: "string", description: "Output format" } + }, + required: ["input"] +} satisfies JSONSchema; +``` + +### `result_schema.ts` + +```typescript +import { JSONSchema } from "@llumiverse/common"; + +export default { + type: "object", + properties: { + summary: { type: "string", description: "The analysis summary" }, + confidence: { type: "number", description: "Confidence score 0-1" } + }, + required: ["summary"] +} satisfies JSONSchema; +``` + +### `index.ts` (interaction spec) + +```typescript +import { InteractionSpec } from "@vertesia/common"; +import PROMPT from "./prompt.hbs?prompt"; +import result_schema from "./result_schema.js"; + +export default { + name: "analyze_content", + title: "Analyze Content", + description: "Analyzes content and returns a structured summary", + result_schema, + prompts: [PROMPT], + tags: ["analysis", "text"] +} satisfies InteractionSpec; +``` + +--- + +## Interaction (code-based) + +For agents and conversational interactions (no `.hbs` file): + +```typescript +import { PromptRole } from "@llumiverse/common"; +import type { InteractionSpec } from "@vertesia/common"; +import { TemplateType } from "@vertesia/common"; + +export default { + name: "my_assistant", + title: "My Assistant", + description: "A conversational assistant", + tags: ["assistant", "chat"], + agent_runner_options: { + is_agent: true, + }, + prompts: [ + { + role: PromptRole.system, + content: "You are a helpful assistant. Answer questions accurately.", + content_type: TemplateType.text, + }, + { + role: PromptRole.user, + content_type: TemplateType.handlebars, + content: "{{user_prompt}}", + }, + ], +} satisfies InteractionSpec; +``` + +### Collection (`interactions//index.ts`) + +```typescript +import { InteractionCollection } from "@vertesia/tools-sdk"; +import analyzeContent from "./analyze_content/index.js"; +import icon from "./icon.svg.js"; + +export const MyInteractions = new InteractionCollection({ + name: "my-collection", + title: "My Interactions", + description: "Description of this collection", + icon, + interactions: [analyzeContent] +}); +``` + +--- + +## Content Type + +### `.ts` + +```typescript +import { InCodeTypeSpec } from "@vertesia/common"; + +export const MyType = { + name: "my_type", + description: "Description of this content type", + tags: ["category1", "category2"], + object_schema: { + type: "object", + properties: { + title: { type: "string", description: "Title", minLength: 1, maxLength: 200 }, + body: { type: "string", description: "Body content" }, + status: { type: "string", enum: ["draft", "published", "archived"] } + }, + required: ["title"], + additionalProperties: false + }, + table_layout: [ + { field: "properties.title", name: "Title", type: "string" }, + { field: "properties.status", name: "Status", type: "string" }, + { field: "updated_at", name: "Updated", type: "date" } + ], + is_chunkable: true, + strict_mode: true +} satisfies InCodeTypeSpec; +``` + +### Collection (`types//index.ts`) + +```typescript +import { ContentTypesCollection } from "@vertesia/tools-sdk"; +import { MyType } from "./my-type.js"; +import icon from "./icon.svg.js"; + +export const MyTypes = new ContentTypesCollection({ + name: "my-collection", + title: "My Content Types", + description: "Description of this collection", + icon, + types: [MyType] +}); +``` + +--- + +## Rendering Template + +### `TEMPLATE.md` + +```markdown +--- +title: My Report +description: A report template for generating formatted PDFs +tags: [report, pdf] +type: document +--- + +# Report Template + +Instructions for the document generation system. + +## Available Variables + +- `{{title}}` — Report title +- `{{author}}` — Author name +- `{{date}}` — Report date +``` + +**Frontmatter fields:** + +- `description` (required): what this template generates +- `type` (required): `'document'` or `'presentation'` +- `title`: display name +- `tags`: categorization tags + +Asset files (SVG, LaTeX, PNG) in the same directory are auto-discovered and copied to `dist/templates/`. + +### Collection auto-discovery + +```typescript +// templates//index.ts +import { RenderingTemplateCollection } from "@vertesia/tools-sdk"; +import templates from './all?templates'; + +export const MyTemplates = new RenderingTemplateCollection({ + name: "my-collection", + title: "My Templates", + description: "Description of this template collection", + templates // Auto-discovers all subdirs with TEMPLATE.md +}); +``` + +--- + +## Collection registration & icons + +### Adding a collection to its type's index + +```typescript +// src/tool-server/tools/index.ts +import { ExampleTools } from "./examples/index.js"; +import { MyTools } from "./my-collection/index.js"; + +export const tools = [ExampleTools, MyTools]; +``` + +`config.ts` already imports from these per-type index files, so no further wiring is needed once the new collection is in the array. + +### `icon.svg.ts` + +Each collection needs an SVG icon as a default string export: + +```typescript +export default ` + +`; +``` diff --git a/templates/plugin-template/.agents/skills/vertesia-tool-server-resource/SKILL.md b/templates/plugin-template/.agents/skills/vertesia-tool-server-resource/SKILL.md new file mode 100644 index 000000000..242ed6949 --- /dev/null +++ b/templates/plugin-template/.agents/skills/vertesia-tool-server-resource/SKILL.md @@ -0,0 +1,109 @@ +--- +name: vertesia-tool-server-resource +description: Creates tools, skills, interactions, content types, and rendering templates for the Vertesia plugin tool server. Handles file scaffolding, collection registration, and config.ts wiring. Use when adding new tool server resources to this plugin. +--- + +# Vertesia Tool Server Resource + +Step-by-step guide for creating tool server resources. Each resource follows the same workflow: + +1. Create files in the appropriate `src/tool-server///` directory +2. Export from the collection's `index.ts` +3. Register the collection in `src/tool-server//index.ts` (only when adding a new collection) + +For full code templates of every resource type, see `REFERENCE.md`. + +## Conventions + +- All imports use `.js` extensions: `import { x } from "./foo.js"` +- Use `satisfies` for type validation (`{} satisfies Tool`, `{} satisfies InCodeTypeSpec`, …) +- Icons are SVG strings exported as default from `.ts` files +- Import hooks (`?skill`, `?skills`, `?prompt`, `?template`, `?templates`, `?raw`) only work in Rollup-compiled tool-server code, **not** in UI code +- Snake_case for resource names (`my_tool`, `my_type`); PascalCase for TypeScript exports (`MyTool`, `MyType`) +- See `src/tool-server//examples/` for working starter code + +## Resource types + +### Tool + +Three files in `src/tool-server/tools///`: + +| File | Purpose | +|------|---------| +| `schema.ts` | TypeScript interface + JSONSchema (`satisfies JSONSchema`) | +| `.ts` | The `run` function — uses `ToolExecutionPayload

`, returns `ToolResultContent` | +| `index.ts` | `Tool

` definition (`satisfies Tool`) | + +Then export from `tools//index.ts` as a `ToolCollection`. + +→ Code in `REFERENCE.md` § Tool. + +### Skill + +Files in `src/tool-server/skills///`: + +| File | Required | Purpose | +|------|----------|---------| +| `SKILL.md` | yes | YAML frontmatter + instructions for the agent | +| `properties.ts` | no | Runtime gating (`isEnabled`) | +| `*.tsx` | no | Widgets (compiled to `dist/widgets/`) | +| `*.py`, `*.js` | no | Scripts (copied to `dist/scripts/`) | + +Skills are auto-discovered: the collection imports `./all?skills` — no per-skill imports needed. + +→ Code in `REFERENCE.md` § Skill. + +### Interaction + +Two flavors: + +- **Template-based** — `prompt.hbs` + `prompt_schema.ts` + `result_schema.ts` + `index.ts` (`InteractionSpec` importing the prompt via `?prompt`). +- **Code-based** — `index.ts` only, with `prompts: [{ role, content, content_type }, …]` inline. Use this for agents/conversations. + +Then export from `interactions//index.ts` as an `InteractionCollection`. + +→ Code in `REFERENCE.md` § Interaction (template-based) and § Interaction (code-based). + +### Content Type + +One file per type in `src/tool-server/types//.ts` (`InCodeTypeSpec`), then a `ContentTypesCollection` in `types//index.ts`. + +Key fields: `name` (snake_case), `object_schema` (JSON Schema with `additionalProperties: false`), `table_layout` (columns for the UI), `is_chunkable`, `strict_mode`. + +→ Code in `REFERENCE.md` § Content Type. + +### Rendering Template + +Folder per template in `src/tool-server/templates///`: + +- `TEMPLATE.md` with YAML frontmatter (`description`, `type: 'document' | 'presentation'`, optional `title`, `tags`) +- Asset files (SVG, LaTeX, PNG) — auto-discovered, copied to `dist/templates/` + +Templates are auto-discovered: the collection imports `./all?templates`. + +→ Code in `REFERENCE.md` § Rendering Template. + +## Collection registration + +Once a collection is exported from `src/tool-server///index.ts`, add it to the array in `src/tool-server//index.ts`: + +```typescript +// src/tool-server/tools/index.ts +import { ExampleTools } from "./examples/index.js"; +import { MyTools } from "./my-collection/index.js"; + +export const tools = [ExampleTools, MyTools]; +``` + +`config.ts` already wires those per-type index files into the server — no further changes needed there. + +Each collection needs an SVG `icon.svg.ts` (default string export). Code in `REFERENCE.md` § Collection registration & icons. + +## Verification + +After creating a resource: + +1. `pnpm build:server` +2. `pnpm start` +3. Check the admin UI at `http://localhost:3000/` — your resource should appear. +4. Or hit the API: `curl http://localhost:3000/api/tools` (or `/skills`, `/interactions`, `/types`, `/templates`). diff --git a/templates/plugin-template/.agents/skills/vertesia-ui/SKILL.md b/templates/plugin-template/.agents/skills/vertesia-ui/SKILL.md new file mode 100644 index 000000000..d6f7c8c59 --- /dev/null +++ b/templates/plugin-template/.agents/skills/vertesia-ui/SKILL.md @@ -0,0 +1,466 @@ +--- +name: vertesia-ui +description: Reference for building UIs with @vertesia/ui. Covers component API (Input, Button, VModal, VTabs, Table), list/detail tables with infinite scroll, sortable headers, FilterProvider with URL persistence and inline row filters, layout (Sidebar, FullHeightLayout, GenericPageNavHeader), routing (NestedRouterProvider, useParams, useNavigate), agent conversation (ModernAgentConversation), styling, and security. Use when creating or modifying React UI pages or components. +--- + +# Vertesia UI Development + +React UI built with React 19, Tailwind CSS 4, and `@vertesia/ui` components. + +For the full component API reference, see also `composableai/packages/ui/llms.txt` (shipped with the npm package). + +For a reusable list/detail table example with backend search, sort, facets, inline filters, and back-navigation preservation, see `references/generic-table-pattern.md`. + +## Required First Step: Component Inventory + +Before writing or refactoring UI code, explicitly check whether `@vertesia/ui` already provides the surface you need. + +At minimum, search for an existing component in these buckets: + +- `@vertesia/ui/core` +- `@vertesia/ui/layout` +- `@vertesia/ui/features` + +If a suitable component exists, use it. Do not reimplement it with raw HTML just because the local version is faster to type. + +This is especially strict for: + +- tables +- page headers +- filters +- modals +- tabs +- badges +- buttons +- selects +- text inputs +- side panels +- empty/loading/error states + +If you still introduce a custom wrapper, state which existing `@vertesia/ui` component you checked and why it was insufficient. + +## Import Paths + +```tsx +import { Button, Card, Input, VModal, VTabs } from '@vertesia/ui/core'; +import { useFetch, useToast, useIntersectionObserver } from '@vertesia/ui/core'; +import { FilterProvider, FilterBtn, FilterBar, FilterClear } from '@vertesia/ui/core'; +import { useNavigate, useParams, NavLink, NestedRouterProvider } from '@vertesia/ui/router'; +import { useUserSession } from '@vertesia/ui/session'; +import { Sidebar, SidebarSection, SidebarItem, useSidebarToggle, FullHeightLayout } from '@vertesia/ui/layout'; +import { GenericPageNavHeader, ModernAgentConversation } from '@vertesia/ui/features'; +import { VertesiaShell, StandaloneApp } from '@vertesia/ui/shell'; +``` + +## Key Component APIs + +### Input — Value-based onChange (NOT event-based) + +```tsx +// CORRECT — passes value directly + + setName(value.trim())} /> + +// WRONG — this will NOT work + setName(e.target.value)} /> // ❌ +``` + +**Textarea** uses standard React events: `onChange={(e) => setText(e.target.value)}` + +### Button + +```tsx + + + +// Variants: default, destructive, outline, secondary, ghost, link +// Sizes: default, sm, lg, icon +``` + +### VModal + +```tsx + setOpen(false)} size="md"> + Title + Content + + {/* Primary first */} + + + +``` + +**Note:** VModalFooter uses `flex-row-reverse` — primary button first in code, appears on the right. + +### VTabs + +```tsx + }, + { name: 'tab2', label: 'Second', content: }, +]}> + + + +``` + +### Table + +```tsx +import { Table, TBody, THead, Th, Tr, Td } from '@vertesia/ui/core'; + + + + + + + + + + {items.map(item => ( + + + + + ))} + +
NameStatus
{item.name}{item.status}
+``` + +`TBody` renders loading skeletons when `isLoading=true`. **Only use `isLoading` for initial empty-state load**, not for "load more" — show a separate `` below the table for appending. + +Use this instead of custom `` wrappers unless there is a documented gap. + +### Infinite Scroll (Lazy Loading) + +```tsx +import { useIntersectionObserver } from '@vertesia/ui/core'; + +const loadMoreRef = useRef(null); +const fetchGenRef = useRef(0); +const [isReady, setIsReady] = useState(false); + +// Fetch with stale-request prevention +const fetchItems = useCallback((currentOffset: number, append: boolean) => { + const gen = ++fetchGenRef.current; + setIsLoading(true); + client.someApi.list({ limit: PAGE_SIZE, offset: currentOffset }) + .then(data => { + if (gen !== fetchGenRef.current) return; // stale + setItems(prev => append ? [...prev, ...data.items] : data.items); + setHasMore(data.hasNext); + setOffset(currentOffset + data.items.length); + setIsReady(true); + }) + .finally(() => { if (gen === fetchGenRef.current) setIsLoading(false); }); +}, [deps]); + +// Trigger load when sentinel is visible +useIntersectionObserver(loadMoreRef, () => { + if (isReady && hasMore && !isLoading) { + setIsReady(false); + fetchItems(offset, true); + } +}, { threshold: 0.1, deps: [isReady, hasMore, isLoading, offset] }); + +// Reset helper — reuse for initial load, filter changes, refresh +const resetAndFetch = useCallback(() => { + setItems([]); setOffset(0); setHasMore(true); setIsReady(false); + fetchItems(0, false); +}, [fetchItems]); +``` + +```tsx +{/* After the table */} +{isLoading && items.length > 0 &&
} +
+{!isLoading && items.length === 0 &&
No items found
} +``` + +**Always** use a generation counter (`fetchGenRef`) to prevent stale responses from race conditions. + +### Filter System + +```tsx +import { FilterProvider, FilterBtn, FilterBar, FilterClear } from '@vertesia/ui/core'; + +const filterGroups: FilterGroup[] = [ + { name: 'status', placeholder: 'Status', type: 'select', multiple: true, + options: [ + { value: 'active', label: 'Active' }, + { value: 'archived', label: 'Archived' }, + ] }, + { name: 'search', placeholder: 'Search', type: 'text', multiple: false }, +]; + + +
+ + + +
+
+``` + +Sort select options alphabetically by label. `FilterProvider` handles URL persistence automatically. + +### Spinner, ErrorBox, Badge + +```tsx +import { Spinner, ErrorBox, Badge } from '@vertesia/ui/core'; + +if (isLoading) return ; +if (error) return {error.message}; +Status +``` + +## Routing + +Use `NestedRouterProvider` for plugin routing (nested within host app): + +```tsx +const routes = [ + { path: '/', Component: HomePage }, + { path: '/items/:id', Component: ItemPage }, + { path: '*', Component: NotFound }, +]; + +``` + +### Navigation hooks + +```tsx +const navigate = useNavigate(); +navigate('/items/123'); // Push +navigate(-1); // Go back + +const { id } = useParams(); // From /items/:id +const [searchParams] = useSearchParams(); +const path = useLocation().pathname; // Current path +``` + +### NavLink + +```tsx +Go to Items +``` + +### List / Detail Preservation + +If a list page links to a detail page and users are expected to go back, do not keep the list state inside the list page component. + +Preserve these concerns above the route boundary when they matter: + +- active filters +- search query +- sort +- pagination or loaded results +- row selection +- scroll position + +Use a provider above `NestedRouterProvider` or above the list/detail route split. Do not use a module-level singleton. + +If `FilterProvider` is involved, remember that it restores filters from the URL on mount. If your list state also survives route changes, normalize or dedupe filter writes at the provider boundary so the same filter does not get appended repeatedly on back-navigation. + +For scroll restoration: + +- persist the list scroll position in provider state and `window.history.state.data` +- restore it in `useLayoutEffect` +- wait until the list has rendered before restoring, typically with `requestAnimationFrame` + +Do not treat list/detail navigation as a cosmetic issue. If filters and scroll reset on back, the UX is wrong. + +### Search vs Find For Table Surfaces + +For table/list surfaces that need any combination of: + +- full-text search +- backend sort +- backend facets + +use one backend `search` path consistently. + +Do not mix `find` for the default state with `search` for filtered states if the page exposes sort and facet-driven filtering. That creates different backend behavior depending on UI state and weakens the table contract. + +Use `find` only for simple exact-match fetches that do not require backend sort, full-text behavior, or facets. + +## Completion Check + +Before calling a UI task complete, run a short conformance pass: + +1. any raw `
` where `Table` / `THead` / `TBody` should be used? +2. any native ` + + + + + + + navigate(`/items/${id}`)} + /> + + ); +} +``` + +## Table Pattern + +Use `Table`, `THead`, and `TBody` directly. + +```tsx +
+ + + + + + + + + {items.map((item) => ( + onOpen(item.id)} + > + + + ))} + +
+
+ {item.title} + onFilterValue("title", item.title)} + /> +
+
+``` + +## Inline Filter Button Pattern + +Do not assemble Tailwind hover classes dynamically. + +Bad: + +```tsx +className={`opacity-0 group-hover/${groupName}:opacity-100`} +``` + +Good: + +```tsx +const hoverClass = { + title: "group-hover/title:opacity-100", + owner: "group-hover/owner:opacity-100", +}[groupName]; +``` + +```tsx +function InlineFilterButton({ + tooltip, + hoverClass, + onClick, +}: { + tooltip: string; + hoverClass: string; + onClick: () => void; +}) { + return ( + + + + ); +} +``` + +## Filter Deduplication Rule + +If both of these are true: + +- the list state survives route changes +- `FilterProvider` restores filters from the URL on mount + +then filter writes must be normalized or deduped. + +Otherwise, going to detail and back can duplicate chips and query params. + +```tsx +function dedupeFilters(filters: Filter[]) { + const seen = new Set(); + const deduped: Filter[] = []; + + for (const filter of filters) { + const normalizedValue = Array.isArray(filter.value) + ? filter.value.map((entry) => typeof entry === "string" ? entry : `${entry.value}|${entry.label || ""}`) + : []; + const key = [ + filter.name, + filter.type, + filter.multiple ? "multi" : "single", + ...normalizedValue, + ].join("::"); + + if (seen.has(key)) continue; + seen.add(key); + deduped.push(filter); + } + + return deduped; +} +``` + +## Scroll Persistence Pattern + +```tsx +function persistScrollTop(scrollTop: number) { + const state = window.history.state || {}; + window.history.replaceState({ + ...state, + data: { + ...(state.data || {}), + listScrollTop: scrollTop, + }, + }, ""); +} + +function readScrollTop() { + const state = window.history.state as { data?: { listScrollTop?: number } } | null; + return state?.data?.listScrollTop; +} +``` + +Restore in `useLayoutEffect`, not plain `useEffect`. + +## Checklist + +Before calling a list/detail table done: + +1. does the table use `Table`, `THead`, `TBody` directly? +2. does backend search own rows, sort, and facets? +3. is list state above the route boundary? +4. are URL-restored filters deduped or normalized? +5. is scroll restored on back-navigation? +6. are hover-reveal classes literal/static so Tailwind emits them? diff --git a/templates/plugin-template/.agents/skills/vertesia-ui/references/security.md b/templates/plugin-template/.agents/skills/vertesia-ui/references/security.md new file mode 100644 index 000000000..d1f9f052a --- /dev/null +++ b/templates/plugin-template/.agents/skills/vertesia-ui/references/security.md @@ -0,0 +1,89 @@ +# Security Practices for `@vertesia/ui` Pages + +Quick rules and the patterns they correspond to. Apply these to any page or component that takes user input or makes API calls. + +## XSS Prevention + +React escapes JSX content automatically. Be careful only with `dangerouslySetInnerHTML`. + +```tsx +// NEVER use dangerouslySetInnerHTML with unsanitized input +

// ❌ + +// If you must render HTML, sanitize first +import DOMPurify from 'dompurify'; +
// ✅ +``` + +## URL Validation + +Validate user-provided URLs before rendering as links: + +```tsx +function isValidUrl(url: string): boolean { + try { + const parsed = new URL(url); + return ['http:', 'https:'].includes(parsed.protocol); + } catch { return false; } +} + +{isValidUrl(userUrl) && Link} +``` + +## Error Handling + +Never expose internal API details to users. Log full details to console; show generic messages with `useToast`. + +```tsx +try { + await client.objects.retrieve(objectId); +} catch (error) { + console.error('Object retrieval failed:', error); // full details + toast({ status: 'error', title: 'Unable to load data. Please try again.' }); +} +``` + +## Secrets + +- Never hardcode API keys or tokens — use environment variables. +- Prefix client-side env vars with `VITE_` (they are embedded in the bundle). +- Keep sensitive keys server-side only (tool server code, not UI). +- Never commit `.env` files — use `.env.example` for documentation. + +## Form Security + +Validate inputs before submitting (Zod or similar). Validate file uploads (type, size, extension): + +```tsx +const MAX_SIZE = 10 * 1024 * 1024; // 10MB +const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'application/pdf']; + +if (file.size > MAX_SIZE) throw new Error('File too large'); +if (!ALLOWED_TYPES.includes(file.type)) throw new Error('Invalid file type'); +``` + +## Client-Side Throttling + +Disable buttons after the first click and re-enable in a `finally` block. Apply to **every** button that triggers an async action — form submissions, deletions, API calls. + +```tsx +const [isSubmitting, setIsSubmitting] = useState(false); + +async function handleSubmit() { + if (isSubmitting) return; + setIsSubmitting(true); + try { + await client.someApi.doAction(payload); + toast({ status: 'success', title: 'Item saved' }); + } catch (error) { + console.error('Action failed:', error); + toast({ status: 'error', title: 'Unable to save. Please try again.' }); + } finally { + setIsSubmitting(false); + } +} + + +``` diff --git a/templates/plugin-template/AGENTS.md b/templates/plugin-template/AGENTS.md new file mode 120000 index 000000000..681311eb9 --- /dev/null +++ b/templates/plugin-template/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/templates/plugin-template/src/ui/ContentObjectsPage.tsx b/templates/plugin-template/src/ui/ContentObjectsPage.tsx new file mode 100644 index 000000000..ffd377c97 --- /dev/null +++ b/templates/plugin-template/src/ui/ContentObjectsPage.tsx @@ -0,0 +1,452 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ArrowDown, ArrowUp, ArrowUpDown, Filter as FilterIcon } from 'lucide-react'; +import { + Badge, + Button, + type Filter, + type FilterGroup, + type FilterOption, + FilterBar, + FilterBtn, + FilterClear, + FilterProvider, + Input, + Spinner, + TBody, + THead, + Table, + useDebounce, + useFetch, + useIntersectionObserver, + useToast, + VTooltip, +} from '@vertesia/ui/core'; +import { GenericPageNavHeader } from '@vertesia/ui/features'; +import { useUITranslation } from '@vertesia/ui/i18n'; +import { useUserSession } from '@vertesia/ui/session'; +import { + type ContentObjectItem, + type ContentObjectTypeItem, + ContentObjectStatus, +} from '@vertesia/common'; + +const PAGE_SIZE = 50; + +const STATUS_VALUES = Object.values(ContentObjectStatus); + +type SortField = 'name' | 'type' | 'status' | 'updated'; +type SortDir = 'asc' | 'desc'; + +// Elasticsearch text fields can't be sorted directly; use the .keyword sub-field. +// See ElasticsearchIndexManager BASE_INDEX_MAPPING_PROPERTIES. +const SORT_FIELD_MAP: Record = { + name: 'name.keyword', + type: 'type.name', + status: 'status', + updated: 'updated_at', +}; + +type BadgeVariant = + | 'default' + | 'secondary' + | 'destructive' + | 'attention' + | 'success' + | 'info' + | 'done'; + +function statusVariant(status?: ContentObjectStatus): BadgeVariant { + switch (status) { + case ContentObjectStatus.ready: + case ContentObjectStatus.completed: + return 'success'; + case ContentObjectStatus.failed: + return 'destructive'; + case ContentObjectStatus.processing: + case ContentObjectStatus.created: + return 'attention'; + case ContentObjectStatus.archived: + return 'done'; + default: + return 'default'; + } +} + +function getSelectValues(filters: Filter[], name: string): string[] { + const filter = filters.find((f) => f.name === name); + if (!filter || !Array.isArray(filter.value) || filter.value.length === 0) return []; + return filter.value + .map((v) => (typeof v === 'string' ? v : v.value ?? '')) + .filter((v): v is string => Boolean(v)); +} + +export function ContentObjectsPage() { + const { t } = useUITranslation(); + const { client } = useUserSession(); + const toast = useToast(); + + const [query, setQuery] = useState(''); + const [filters, setFilters] = useState([]); + const [sortField, setSortField] = useState('updated'); + const [sortDir, setSortDir] = useState('desc'); + const [types, setTypes] = useState([]); + const [moreItems, setMoreItems] = useState([]); + const [hasMore, setHasMore] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); + + const debouncedQuery = useDebounce(query, 300); + + const loadMoreRef = useRef(null); + + const buildQuery = useCallback(() => { + const typeIds = getSelectValues(filters, 'type'); + const statusValues = getSelectValues(filters, 'status'); + const trimmed = debouncedQuery.trim(); + return { + ...(trimmed ? { full_text: trimmed } : {}), + ...(typeIds.length ? { types: typeIds } : {}), + ...(statusValues.length ? { status: statusValues } : {}), + }; + }, [debouncedQuery, filters]); + + const sortPayload = useMemo( + () => [{ field: SORT_FIELD_MAP[sortField], order: sortDir }], + [sortField, sortDir], + ); + + const { data: firstPage, isLoading } = useFetch( + async () => { + const result = await client.objects.search({ + query: buildQuery(), + limit: PAGE_SIZE, + offset: 0, + sort: sortPayload, + }); + return result.results ?? []; + }, + { + deps: [debouncedQuery, filters, sortField, sortDir], + onSuccess: (results) => { + setMoreItems([]); + setHasMore(results.length >= PAGE_SIZE); + }, + onError: (err) => { + console.error('Content object search failed:', err); + toast({ status: 'error', title: t('objects.searchError') }); + }, + }, + ); + + useEffect(() => { + client.store.types + .list({ limit: 200 }) + .then(setTypes) + .catch((err) => console.error('Failed to load content types:', err)); + }, [client]); + + const items = useMemo(() => [...(firstPage ?? []), ...moreItems], [firstPage, moreItems]); + + const loadMore = useCallback(() => { + if (isLoadingMore || !hasMore || isLoading) return; + setIsLoadingMore(true); + const offset = items.length; + client.objects + .search({ + query: buildQuery(), + limit: PAGE_SIZE, + offset, + sort: sortPayload, + }) + .then((result) => { + const results = result.results ?? []; + setMoreItems((prev) => [...prev, ...results]); + setHasMore(results.length >= PAGE_SIZE); + }) + .catch((err) => { + console.error('Load more failed:', err); + toast({ status: 'error', title: t('objects.searchError') }); + }) + .finally(() => setIsLoadingMore(false)); + }, [ + client, + buildQuery, + sortPayload, + items.length, + isLoadingMore, + isLoading, + hasMore, + toast, + t, + ]); + + useIntersectionObserver(loadMoreRef, loadMore, { + threshold: 0.1, + deps: [loadMore], + }); + + const handleSort = useCallback( + (field: SortField) => { + if (sortField === field) { + setSortDir((current) => (current === 'asc' ? 'desc' : 'asc')); + return; + } + setSortField(field); + setSortDir('asc'); + }, + [sortField], + ); + + const addFilterValue = useCallback( + (name: 'type' | 'status', value: string, label: string) => { + const placeholder = + name === 'type' ? t('objects.filterType') : t('objects.filterStatus'); + const newOption: FilterOption = { value, label }; + setFilters((prev) => { + const existing = prev.find((f) => f.name === name); + if (!existing) { + return [ + ...prev, + { + name, + placeholder, + type: 'select', + multiple: true, + value: [newOption], + }, + ]; + } + const currentValues = Array.isArray(existing.value) ? existing.value : []; + const alreadyHas = currentValues.some( + (v) => (typeof v === 'string' ? v : v.value) === value, + ); + if (alreadyHas) return prev; + return prev.map((f) => + f === existing + ? { ...f, value: [...(f.value as FilterOption[]), newOption] } + : f, + ); + }); + }, + [t], + ); + + const filterGroups: FilterGroup[] = useMemo( + () => [ + { + name: 'type', + placeholder: t('objects.filterType'), + type: 'select', + multiple: true, + options: [...types] + .map((typ) => ({ value: typ.id, label: typ.name })) + .sort((a, b) => a.label.localeCompare(b.label)), + }, + { + name: 'status', + placeholder: t('objects.filterStatus'), + type: 'select', + multiple: true, + options: STATUS_VALUES.map((s) => ({ + value: s, + label: t(`objects.status.${s}`), + })).sort((a, b) => a.label.localeCompare(b.label)), + }, + ], + [types, t], + ); + + const showLoadMoreSpinner = isLoadingMore; + const showEmpty = !isLoading && items.length === 0; + + return ( +
+ +
+ +
+ + + +
+ +
+ +
+ + + + + + + + + + + {items.map((item) => ( + + ))} + +
+ {showLoadMoreSpinner && ( +
+ +
+ )} +
+ {showEmpty && ( +
+ {t('objects.empty')} +
+ )} +
+
+
+ ); +} + +interface SortableHeadProps { + field: SortField; + label: string; + activeField: SortField; + direction: SortDir; + onSort: (field: SortField) => void; +} + +function SortableHead({ field, label, activeField, direction, onSort }: SortableHeadProps) { + const isActive = activeField === field; + const Icon = isActive ? (direction === 'asc' ? ArrowUp : ArrowDown) : ArrowUpDown; + return ( + onSort(field)} + > +
+ {label} +
+ + ); +} + +interface InlineFilterButtonProps { + tooltip: string; + hoverClass: string; + onClick: () => void; +} + +function InlineFilterButton({ tooltip, hoverClass, onClick }: InlineFilterButtonProps) { + return ( + + + + ); +} + +interface ContentObjectRowProps { + item: ContentObjectItem; + t: (key: string, opts?: Record) => string; + onAddFilter: (name: 'type' | 'status', value: string, label: string) => void; +} + +function ContentObjectRow({ item, t, onAddFilter }: ContentObjectRowProps) { + const updated = item.updated_at ? new Date(item.updated_at).toLocaleString() : '—'; + const typeName = item.type?.name; + const typeId = item.type && 'id' in item.type ? item.type.id : undefined; + const statusLabel = item.status ? t(`objects.status.${item.status}`) : '—'; + + return ( + + +
+ {item.name || item.id} + {item.description && ( + + {item.description} + + )} +
+ + +
+ {typeName ?? '—'} + {typeId && typeName && ( + onAddFilter('type', typeId, typeName)} + /> + )} +
+ + +
+ {statusLabel} + {item.status && ( + onAddFilter('status', item.status, statusLabel)} + /> + )} +
+ + {updated} + + ); +} diff --git a/templates/plugin-template/src/ui/PluginSidebar.tsx b/templates/plugin-template/src/ui/PluginSidebar.tsx index b548a80c1..5dca0b78e 100644 --- a/templates/plugin-template/src/ui/PluginSidebar.tsx +++ b/templates/plugin-template/src/ui/PluginSidebar.tsx @@ -4,7 +4,7 @@ import { useUITranslation } from '@vertesia/ui/i18n'; import { SidebarSection, useSidebarToggle } from '@vertesia/ui/layout'; import { useLocation, useRouterBasePath } from '@vertesia/ui/router'; import { useUserSession } from '@vertesia/ui/session'; -import { HomeIcon, MessageSquare, PlusCircle } from 'lucide-react'; +import { Database, HomeIcon, MessageSquare, PlusCircle } from 'lucide-react'; import type { AgentRunResponse, WorkflowRun } from '@vertesia/common'; import { AppSidebarItem } from './AppSidebarItem'; import { ASSISTANT_INTERACTION } from './constants'; @@ -104,6 +104,14 @@ export function PluginSidebar() { > {t('nav.home')} + + {t('nav.objects')} + Date: Fri, 8 May 2026 10:22:54 +0900 Subject: [PATCH 53/75] add corepack variable --- templates/plugin-template/vercel.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/templates/plugin-template/vercel.json b/templates/plugin-template/vercel.json index 93ca6ab61..805e9f75c 100644 --- a/templates/plugin-template/vercel.json +++ b/templates/plugin-template/vercel.json @@ -1,6 +1,11 @@ { "buildCommand": "npm run build", "outputDirectory": "dist", + "build": { + "env": { + "ENABLE_EXPERIMENTAL_COREPACK": "1" + } + }, "redirects": [ { "source": "/", From f4cd30f5c26c20b0705bdfd222669d7fd4e96733 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Vachette?= <5880528+michaelva@users.noreply.github.com> Date: Fri, 8 May 2026 10:23:02 +0900 Subject: [PATCH 54/75] add i18n --- .../src/ui/i18n/locales/en.json | 20 ++++++++++++++++++- .../src/ui/i18n/locales/fr.json | 20 ++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/templates/plugin-template/src/ui/i18n/locales/en.json b/templates/plugin-template/src/ui/i18n/locales/en.json index fcb6feb0c..d8ad8c225 100644 --- a/templates/plugin-template/src/ui/i18n/locales/en.json +++ b/templates/plugin-template/src/ui/i18n/locales/en.json @@ -9,11 +9,29 @@ "nav.conversation": "Conversation", "nav.home": "Home", "nav.newChat": "New Chat", + "nav.objects": "Content Objects", "nav.pluginAssistant": "Plugin Assistant", "nav.signOut": "Sign out", "nav.templateDescription": "This is the plugin template. Use it as a starting point to build your own plugin UI.", "nav.today": "Today", "nav.tryAgentChat": "Try the Agent Chat", "nav.welcome": "Welcome, {{name}}!", - "nav.yesterday": "Yesterday" + "nav.yesterday": "Yesterday", + "objects.col.name": "Name", + "objects.col.status": "Status", + "objects.col.type": "Type", + "objects.col.updated": "Updated", + "objects.empty": "No content objects found.", + "objects.filterByValue": "Filter by {{value}}", + "objects.filterStatus": "Status", + "objects.filterType": "Type", + "objects.searchError": "Failed to search content objects.", + "objects.searchPlaceholder": "Search content objects...", + "objects.status.archived": "Archived", + "objects.status.completed": "Completed", + "objects.status.created": "Created", + "objects.status.failed": "Failed", + "objects.status.processing": "Processing", + "objects.status.ready": "Ready", + "objects.title": "Content Objects" } diff --git a/templates/plugin-template/src/ui/i18n/locales/fr.json b/templates/plugin-template/src/ui/i18n/locales/fr.json index a41b25da0..1ba464bcf 100644 --- a/templates/plugin-template/src/ui/i18n/locales/fr.json +++ b/templates/plugin-template/src/ui/i18n/locales/fr.json @@ -9,11 +9,29 @@ "nav.conversation": "Conversation", "nav.home": "Accueil", "nav.newChat": "Nouvelle conversation", + "nav.objects": "Objets de contenu", "nav.pluginAssistant": "Assistant du plugin", "nav.signOut": "Se déconnecter", "nav.templateDescription": "Ceci est le modèle de plugin. Utilisez-le comme point de départ pour créer votre propre interface de plugin.", "nav.today": "Aujourd'hui", "nav.tryAgentChat": "Essayer le chat agent", "nav.welcome": "Bienvenue, {{name}} !", - "nav.yesterday": "Hier" + "nav.yesterday": "Hier", + "objects.col.name": "Nom", + "objects.col.status": "Statut", + "objects.col.type": "Type", + "objects.col.updated": "Mis à jour", + "objects.empty": "Aucun objet de contenu trouvé.", + "objects.filterByValue": "Filtrer par {{value}}", + "objects.filterStatus": "Statut", + "objects.filterType": "Type", + "objects.searchError": "Échec de la recherche d'objets de contenu.", + "objects.searchPlaceholder": "Rechercher des objets de contenu...", + "objects.status.archived": "Archivé", + "objects.status.completed": "Terminé", + "objects.status.created": "Créé", + "objects.status.failed": "Échec", + "objects.status.processing": "En cours", + "objects.status.ready": "Prêt", + "objects.title": "Objets de contenu" } From 18ff1254603729c598c73da5e7e059a90790958d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Vachette?= <5880528+michaelva@users.noreply.github.com> Date: Fri, 8 May 2026 10:31:57 +0900 Subject: [PATCH 55/75] add env --- templates/plugin-template/.env.app.template | 6 ++++++ templates/plugin-template/src/ui/env.ts | 6 +++--- templates/plugin-template/src/ui/vite-env.d.ts | 11 +++++++++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/templates/plugin-template/.env.app.template b/templates/plugin-template/.env.app.template index 8f4a4d9e1..68be79f4a 100644 --- a/templates/plugin-template/.env.app.template +++ b/templates/plugin-template/.env.app.template @@ -2,3 +2,9 @@ # Required by `vite dev --mode app` and `vite build --mode app`. # This is public Vite build-time config, not a secret. VITE_APP_NAME={{PROJECT_NAME}} + +# Optional Vertesia endpoint overrides. Defaults in src/ui/env.ts target dev-preview. +# Uncomment and set these in .env.local to point at a different environment. +# VITE_STUDIO_URL=https://api.vertesia.io +# VITE_ZENO_URL=https://api.vertesia.io +# VITE_STS_URL=https://sts.vertesia.io diff --git a/templates/plugin-template/src/ui/env.ts b/templates/plugin-template/src/ui/env.ts index a20bf799e..314d98998 100644 --- a/templates/plugin-template/src/ui/env.ts +++ b/templates/plugin-template/src/ui/env.ts @@ -11,8 +11,8 @@ Env.init({ isDocker: true, type: "development", endpoints: { - studio: "https://studio-server-dev-preview.api.dev1.vertesia.io", - zeno: "https://zeno-server-dev-preview.api.dev1.vertesia.io", - sts: "https://sts.dev1.vertesia.io", + studio: import.meta.env.VITE_STUDIO_URL ?? "https://api.vertesia.io", + zeno: import.meta.env.VITE_ZENO_URL ?? "https://api.vertesia.io", + sts: import.meta.env.VITE_STS_URL ?? "https://sts.vertesia.io", } }); diff --git a/templates/plugin-template/src/ui/vite-env.d.ts b/templates/plugin-template/src/ui/vite-env.d.ts index 11f02fe2a..2c649d67d 100644 --- a/templates/plugin-template/src/ui/vite-env.d.ts +++ b/templates/plugin-template/src/ui/vite-env.d.ts @@ -1 +1,12 @@ /// + +interface ImportMetaEnv { + readonly VITE_APP_NAME: string; + readonly VITE_STUDIO_URL?: string; + readonly VITE_ZENO_URL?: string; + readonly VITE_STS_URL?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} From ac959fa3fb8304904567e06f67af8f02ef19dd67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Vachette?= <5880528+michaelva@users.noreply.github.com> Date: Fri, 8 May 2026 10:37:51 +0900 Subject: [PATCH 56/75] remove obsolete templates --- packages/create-plugin/src/configuration.ts | 12 - templates/tool-server-template/.env.example | 14 - templates/tool-server-template/.gitignore | 44 -- .../tool-server-template/.vscode/launch.json | 30 - .../tool-server-template/.vscode/tasks.json | 19 - templates/tool-server-template/LICENSE | 13 - templates/tool-server-template/README.md | 611 ------------------ templates/tool-server-template/TESTING.md | 480 -------------- templates/tool-server-template/api/index.js | 31 - templates/tool-server-template/package.json | 56 -- .../rollup.config.bundle.js | 60 -- .../tool-server-template/rollup.config.js | 85 --- .../rollup.config.widgets.js | 109 ---- .../tool-server-template/src/build-site.ts | 122 ---- templates/tool-server-template/src/config.ts | 17 - .../tool-server-template/src/imports.d.ts | 37 -- .../src/interactions/examples/icon.svg.ts | 7 - .../src/interactions/examples/index.ts | 11 - .../interactions/examples/what_color/index.ts | 12 - .../examples/what_color/prompt.hbs | 6 - .../examples/what_color/prompt_schema.ts | 12 - .../examples/what_color/result_schema.ts | 16 - .../src/interactions/index.ts | 5 - .../src/mcp/MCPProvider.ts | 23 - .../tool-server-template/src/mcp/index.ts | 4 - .../tool-server-template/src/server-node.ts | 33 - templates/tool-server-template/src/server.ts | 7 - .../src/skills/examples/index.ts | 9 - .../src/skills/examples/user-select/SKILL.md | 110 ---- .../examples/user-select/user-select.tsx | 245 ------- .../tool-server-template/src/skills/index.ts | 5 - .../tools/examples/calculator/calculator.ts | 52 -- .../src/tools/examples/calculator/index.ts | 10 - .../src/tools/examples/calculator/schema.ts | 16 - .../src/tools/examples/icon.svg.ts | 7 - .../src/tools/examples/index.ts | 11 - .../tool-server-template/src/tools/index.ts | 5 - .../tool-server-template/template.config.json | 30 - templates/tool-server-template/tsconfig.json | 36 -- .../tsconfig.widgets.json | 31 - templates/tool-server-template/vercel.json | 29 - .../ui-plugin-template/.env.local.template | 1 - templates/ui-plugin-template/.gitignore | 23 - templates/ui-plugin-template/LICENSE | 13 - templates/ui-plugin-template/README.md | 162 ----- templates/ui-plugin-template/eslint.config.js | 29 - templates/ui-plugin-template/index.html | 26 - templates/ui-plugin-template/package.json | 49 -- templates/ui-plugin-template/src/app.tsx | 8 - templates/ui-plugin-template/src/assets.ts | 26 - templates/ui-plugin-template/src/env.ts | 16 - templates/ui-plugin-template/src/index.css | 21 - templates/ui-plugin-template/src/main.tsx | 24 - templates/ui-plugin-template/src/pages.tsx | 31 - templates/ui-plugin-template/src/plugin.tsx | 21 - templates/ui-plugin-template/src/routes.tsx | 17 - .../ui-plugin-template/src/vite-env.d.ts | 1 - .../ui-plugin-template/template.config.json | 59 -- .../ui-plugin-template/tsconfig.app.json | 51 -- templates/ui-plugin-template/tsconfig.json | 7 - .../ui-plugin-template/tsconfig.node.json | 27 - templates/ui-plugin-template/vite.config.ts | 128 ---- templates/worker-template/.docker/config.json | 5 - templates/worker-template/.dockerignore | 7 - templates/worker-template/.env.template | 16 - templates/worker-template/.gitignore | 25 - templates/worker-template/.npmrc | 1 - templates/worker-template/Dockerfile | 28 - templates/worker-template/README.md | 218 ------- .../worker-template/bin/bundle-workflows.mjs | 39 -- templates/worker-template/eslint.config.js | 34 - templates/worker-template/package.json | 62 -- .../worker-template/src/activities.test.ts | 146 ----- templates/worker-template/src/activities.ts | 122 ---- .../worker-template/src/debug-replayer.ts | 15 - templates/worker-template/src/main.ts | 59 -- templates/worker-template/src/test/utils.ts | 49 -- templates/worker-template/src/vitest.d.ts | 7 - .../worker-template/src/workflows.test.ts | 216 ------- templates/worker-template/src/workflows.ts | 104 --- .../worker-template/template.config.json | 57 -- .../worker-template/tsconfig.eslint.json | 4 - templates/worker-template/tsconfig.json | 44 -- templates/worker-template/tsconfig.test.json | 15 - templates/worker-template/vitest.config.ts | 13 - templates/worker-template/vitest.setup.ts | 15 - 86 files changed, 4513 deletions(-) delete mode 100644 templates/tool-server-template/.env.example delete mode 100644 templates/tool-server-template/.gitignore delete mode 100644 templates/tool-server-template/.vscode/launch.json delete mode 100644 templates/tool-server-template/.vscode/tasks.json delete mode 100644 templates/tool-server-template/LICENSE delete mode 100644 templates/tool-server-template/README.md delete mode 100644 templates/tool-server-template/TESTING.md delete mode 100644 templates/tool-server-template/api/index.js delete mode 100644 templates/tool-server-template/package.json delete mode 100644 templates/tool-server-template/rollup.config.bundle.js delete mode 100644 templates/tool-server-template/rollup.config.js delete mode 100644 templates/tool-server-template/rollup.config.widgets.js delete mode 100644 templates/tool-server-template/src/build-site.ts delete mode 100644 templates/tool-server-template/src/config.ts delete mode 100644 templates/tool-server-template/src/imports.d.ts delete mode 100644 templates/tool-server-template/src/interactions/examples/icon.svg.ts delete mode 100644 templates/tool-server-template/src/interactions/examples/index.ts delete mode 100644 templates/tool-server-template/src/interactions/examples/what_color/index.ts delete mode 100644 templates/tool-server-template/src/interactions/examples/what_color/prompt.hbs delete mode 100644 templates/tool-server-template/src/interactions/examples/what_color/prompt_schema.ts delete mode 100644 templates/tool-server-template/src/interactions/examples/what_color/result_schema.ts delete mode 100644 templates/tool-server-template/src/interactions/index.ts delete mode 100644 templates/tool-server-template/src/mcp/MCPProvider.ts delete mode 100644 templates/tool-server-template/src/mcp/index.ts delete mode 100644 templates/tool-server-template/src/server-node.ts delete mode 100644 templates/tool-server-template/src/server.ts delete mode 100644 templates/tool-server-template/src/skills/examples/index.ts delete mode 100644 templates/tool-server-template/src/skills/examples/user-select/SKILL.md delete mode 100644 templates/tool-server-template/src/skills/examples/user-select/user-select.tsx delete mode 100644 templates/tool-server-template/src/skills/index.ts delete mode 100644 templates/tool-server-template/src/tools/examples/calculator/calculator.ts delete mode 100644 templates/tool-server-template/src/tools/examples/calculator/index.ts delete mode 100644 templates/tool-server-template/src/tools/examples/calculator/schema.ts delete mode 100644 templates/tool-server-template/src/tools/examples/icon.svg.ts delete mode 100644 templates/tool-server-template/src/tools/examples/index.ts delete mode 100644 templates/tool-server-template/src/tools/index.ts delete mode 100644 templates/tool-server-template/template.config.json delete mode 100644 templates/tool-server-template/tsconfig.json delete mode 100644 templates/tool-server-template/tsconfig.widgets.json delete mode 100644 templates/tool-server-template/vercel.json delete mode 100644 templates/ui-plugin-template/.env.local.template delete mode 100644 templates/ui-plugin-template/.gitignore delete mode 100644 templates/ui-plugin-template/LICENSE delete mode 100644 templates/ui-plugin-template/README.md delete mode 100644 templates/ui-plugin-template/eslint.config.js delete mode 100644 templates/ui-plugin-template/index.html delete mode 100644 templates/ui-plugin-template/package.json delete mode 100644 templates/ui-plugin-template/src/app.tsx delete mode 100644 templates/ui-plugin-template/src/assets.ts delete mode 100644 templates/ui-plugin-template/src/env.ts delete mode 100644 templates/ui-plugin-template/src/index.css delete mode 100644 templates/ui-plugin-template/src/main.tsx delete mode 100644 templates/ui-plugin-template/src/pages.tsx delete mode 100644 templates/ui-plugin-template/src/plugin.tsx delete mode 100644 templates/ui-plugin-template/src/routes.tsx delete mode 100644 templates/ui-plugin-template/src/vite-env.d.ts delete mode 100644 templates/ui-plugin-template/template.config.json delete mode 100644 templates/ui-plugin-template/tsconfig.app.json delete mode 100644 templates/ui-plugin-template/tsconfig.json delete mode 100644 templates/ui-plugin-template/tsconfig.node.json delete mode 100644 templates/ui-plugin-template/vite.config.ts delete mode 100644 templates/worker-template/.docker/config.json delete mode 100644 templates/worker-template/.dockerignore delete mode 100644 templates/worker-template/.env.template delete mode 100644 templates/worker-template/.gitignore delete mode 100644 templates/worker-template/.npmrc delete mode 100644 templates/worker-template/Dockerfile delete mode 100644 templates/worker-template/README.md delete mode 100755 templates/worker-template/bin/bundle-workflows.mjs delete mode 100644 templates/worker-template/eslint.config.js delete mode 100644 templates/worker-template/package.json delete mode 100644 templates/worker-template/src/activities.test.ts delete mode 100644 templates/worker-template/src/activities.ts delete mode 100644 templates/worker-template/src/debug-replayer.ts delete mode 100644 templates/worker-template/src/main.ts delete mode 100644 templates/worker-template/src/test/utils.ts delete mode 100644 templates/worker-template/src/vitest.d.ts delete mode 100644 templates/worker-template/src/workflows.test.ts delete mode 100644 templates/worker-template/src/workflows.ts delete mode 100644 templates/worker-template/template.config.json delete mode 100644 templates/worker-template/tsconfig.eslint.json delete mode 100644 templates/worker-template/tsconfig.json delete mode 100644 templates/worker-template/tsconfig.test.json delete mode 100644 templates/worker-template/vitest.config.ts delete mode 100644 templates/worker-template/vitest.setup.ts diff --git a/packages/create-plugin/src/configuration.ts b/packages/create-plugin/src/configuration.ts index f9d19c9d5..0b76e21bd 100644 --- a/packages/create-plugin/src/configuration.ts +++ b/packages/create-plugin/src/configuration.ts @@ -27,18 +27,6 @@ export const config = { { name: 'Vertesia Plugin', repository: 'vertesia/composableai/templates/plugin-template' - }, - { - name: 'Vertesia Tool Server (deprecated)', - repository: 'vertesia/composableai/templates/tool-server-template' - }, - { - name: 'Vertesia UI Plugin (deprecated)', - repository: 'vertesia/composableai/templates/ui-plugin-template' - }, - { - name: 'Vertesia Workflow Worker', - repository: 'vertesia/composableai/templates/worker-template' } ] as TemplateDefinition[], diff --git a/templates/tool-server-template/.env.example b/templates/tool-server-template/.env.example deleted file mode 100644 index e2116b00d..000000000 --- a/templates/tool-server-template/.env.example +++ /dev/null @@ -1,14 +0,0 @@ -# Environment Variables Template -# Copy this file to .env and fill in your values - -# GitHub App Integration (optional) -# GITHUB_APP_ID=your-app-id -# GITHUB_APP_PRIVATE_KEY_FILE=path/to/private-key.pem - -# Server Configuration -# PORT=5174 -# NODE_ENV=development - -# API Keys (if needed) -# API_KEY=your-api-key -# SECRET=your-secret diff --git a/templates/tool-server-template/.gitignore b/templates/tool-server-template/.gitignore deleted file mode 100644 index f34fb3299..000000000 --- a/templates/tool-server-template/.gitignore +++ /dev/null @@ -1,44 +0,0 @@ -# Dependencies -node_modules/ -.pnp -.pnp.js - -# Build outputs -lib/ -dist/ -*.tsbuildinfo - -# Environment variables -.env -.env.local -.env.*.local -*.pem - -# IDE -.idea/ -*.swp -*.swo -*~ - -# OS -.DS_Store -Thumbs.db - -# Logs -logs/ -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* - -# Vercel -.vercel - -# Testing -coverage/ -.nyc_output/ - -# Temporary files -*.tmp -.cache/ diff --git a/templates/tool-server-template/.vscode/launch.json b/templates/tool-server-template/.vscode/launch.json deleted file mode 100644 index 4a416de39..000000000 --- a/templates/tool-server-template/.vscode/launch.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "version": "0.2.0", - "configurations": [ - { - "type": "node", - "request": "launch", - "name": "Debug Tool Server", - "preLaunchTask": "build", - "program": "${workspaceFolder}/lib/server-node.js", - "cwd": "${workspaceFolder}", - "env": { - "PORT": "5174" - }, - "sourceMaps": true, - "outFiles": [ - "${workspaceFolder}/lib/**/*.js" - ], - "skipFiles": [ - "/**" - ], - "resolveSourceMapLocations": [ - "${workspaceFolder}/**", - "**/node_modules/@vertesia/**", - "!**/node_modules/**" - ], - "console": "integratedTerminal", - "internalConsoleOptions": "neverOpen" - } - ] -} \ No newline at end of file diff --git a/templates/tool-server-template/.vscode/tasks.json b/templates/tool-server-template/.vscode/tasks.json deleted file mode 100644 index d7ee4dce7..000000000 --- a/templates/tool-server-template/.vscode/tasks.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "version": "2.0.0", - "tasks": [ - { - "label": "build", - "type": "shell", - "command": "pnpm run build", - "problemMatcher": ["$tsc"], - "group": { - "kind": "build", - "isDefault": true - }, - "presentation": { - "reveal": "silent", - "panel": "shared" - } - } - ] -} diff --git a/templates/tool-server-template/LICENSE b/templates/tool-server-template/LICENSE deleted file mode 100644 index 2642274ec..000000000 --- a/templates/tool-server-template/LICENSE +++ /dev/null @@ -1,13 +0,0 @@ -Copyright 2026 Vertesia - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - -http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. \ No newline at end of file diff --git a/templates/tool-server-template/README.md b/templates/tool-server-template/README.md deleted file mode 100644 index f8b84e69d..000000000 --- a/templates/tool-server-template/README.md +++ /dev/null @@ -1,611 +0,0 @@ -# Tool Server - -A template for building custom tool servers that expose LLM tools, skills, interactions, and MCP providers. Built with [Hono](https://hono.dev/) for flexible deployment to Vercel Functions or Node.js HTTP servers. - -## Features - -- 🛠️ **Tools**: Executable functions that can be invoked via API (e.g., calculator, API integrations) -- 🎯 **Skills**: AI capabilities defined as markdown prompts with optional helper scripts -- 🔄 **Interactions**: Multi-step agent workflows with templated prompts -- 🔌 **MCP Providers**: Model Context Protocol integrations (optional) -- 📄 **Auto-generated HTML**: Browse and explore resources with automatically generated pages -- 🚀 **Flexible Deployment**: Deploy to Vercel Functions, Cloud Run, Railway, or any Node.js host -- 📦 **Browser Bundles**: Standalone browser-ready bundles for client-side usage -- 🔧 **Simple Build**: Single Rollup config handles TypeScript, raw imports, and bundling - -## Project Structure - -``` -tool-server-template/ -├── src/ -│ ├── tools/ # Tool collections -│ │ └── calculator/ # Example: calculator tool -│ │ ├── manifest.ts -│ │ ├── calculator.ts -│ │ ├── icon.svg.ts -│ │ └── index.ts -│ ├── skills/ # Skill collections -│ │ └── code-review/ # Example: code review skill -│ │ └── SKILL.md -│ ├── interactions/ # Interaction collections -│ │ └── summarize/ # Example: text summarization -│ │ └── text_summarizer/ -│ │ ├── prompt.jst -│ │ └── index.ts -│ ├── server.ts # Hono server entry point -│ └── build-site.ts # Static HTML generator -├── api/ -│ └── index.js # Vercel adapter -├── lib/ # Compiled code (TypeScript → JavaScript) -│ ├── server.js -│ ├── server-node.js -│ ├── tools/ -│ └── ... -├── dist/ # Static HTML pages (public files) -│ ├── index.html -│ ├── tools/ -│ ├── skills/ -│ └── interactions/ -├── public/ # Static assets -├── package.json -├── rollup.config.js # Unified build configuration (TypeScript + bundles) -├── vercel.json # Vercel deployment config -└── tsconfig.json -``` - -## Getting Started - -### Prerequisites - -- Node.js 18+ -- npm or pnpm - -### Installation - -```bash -# Install dependencies -npm install -# or -pnpm install -``` - -### Development - -Start the development server with automatic rebuild and restart: - -```bash -npm run dev -``` - -This will: -1. **Initial build** - Compiles TypeScript and generates HTML pages -2. **Rollup watch mode** - Rebuilds TypeScript on file changes -3. **Node.js with --watch** - Restarts server when lib/ changes - -The server will be available at: -- API: http://localhost:3000/api -- Web UI: http://localhost:3000 - -To use a different port: -```bash -PORT=8080 npm run dev -``` - -**Manual control (advanced):** -```bash -# Terminal 1: Build on changes -npm run build:watch - -# Terminal 2: Server with auto-restart -npm run start:watch -``` - -### Building - -Build the project for production: - -```bash -npm run build -``` - -This will: -1. **Rollup**: Compile TypeScript to JavaScript in `lib/` (ESM with preserveModules) -2. **Copy assets**: Copy skill assets (.md, .py files) to `lib/` -3. **Generate HTML**: Create static HTML pages in `dist/` -4. **Rollup**: Create browser bundles in `dist/libs/` - -The build uses a single **rollup.config.js** that handles: -- TypeScript compilation with `@rollup/plugin-typescript` → `lib/` -- `?raw` imports for template files (via custom rawPlugin) -- Browser bundles with tree-shaking and minification → `dist/libs/` - -**Output structure:** -- `lib/` = Compiled code (what you run) -- `dist/` = Static HTML + browser bundles (what you serve) - -## Creating Resources - -### 1. Creating a Tool - -Tools are executable functions that can be invoked via API. - -**Structure:** -``` -src/tools/my-tool/ -├── manifest.ts # Tool metadata and schema -├── my-tool.ts # Implementation -├── icon.svg.ts # SVG icon -└── index.ts # Collection export -``` - -**Example: manifest.ts** -```typescript -import { ToolDefinition } from "@vertesia/tools-sdk"; - -export default { - name: "my-tool", - description: "Description of what this tool does", - input_schema: { - type: "object", - properties: { - param1: { - type: "string", - description: "First parameter" - } - }, - required: ["param1"] - } -} satisfies ToolDefinition; -``` - -**Example: my-tool.ts** -```typescript -import { Tool, ToolExecutionContext } from "@vertesia/tools-sdk"; -import manifest from "./manifest.js"; - -interface MyToolParams { - param1: string; -} - -async function execute( - params: MyToolParams, - context: ToolExecutionContext -): Promise { - // Tool implementation - return `Processed: ${params.param1}`; -} - -export const MyTool = { - ...manifest, - run: execute -} satisfies Tool; -``` - -**Example: index.ts** -```typescript -import { ToolCollection } from "@vertesia/tools-sdk"; -import { MyTool } from "./my-tool.js"; -import icon from "./icon.svg.js"; - -export const MyTools = new ToolCollection({ - name: "my-tool", - title: "My Tools", - description: "Description of the tool collection", - icon, - tools: [MyTool] -}); -``` - -**Register the collection:** - -Add to `src/tools/index.ts`: -```typescript -import { MyTools } from "./my-tool/index.js"; - -export const tools = [ - MyTools, - // ... other collections -]; -``` - -### 2. Creating a Skill - -Skills are AI capabilities defined as markdown prompts. - -**Structure:** -``` -src/skills/my-skill/ -├── SKILL.md # Skill definition -└── helper.py # Optional helper script -``` - -**Example: SKILL.md** -```markdown ---- -name: my-skill -title: My Skill -keywords: keyword1, keyword2, keyword3 -tools: tool1, tool2 -packages: package1==1.0.0 ---- - -# My Skill - -You are an AI assistant with expertise in [domain]. - -## Instructions - -1. First instruction -2. Second instruction -3. Third instruction - -## Guidelines - -- Guideline 1 -- Guideline 2 -``` - -**Register the collection:** - -Create `src/skills/my-skill/index.ts`: -```typescript -import { SkillCollection, loadSkillsFromDirectory } from "@vertesia/tools-sdk"; - -export const MySkills = new SkillCollection({ - name: "my-skill", - title: "My Skills", - description: "Description of the skill collection", - skills: loadSkillsFromDirectory(new URL(".", import.meta.url).pathname) -}); -``` - -Add to `src/skills/index.ts`: -```typescript -import { MySkills } from "./my-skill/index.js"; - -export const skills = [ - MySkills, - // ... other collections -]; -``` - -### 3. Creating an Interaction - -Interactions are multi-step workflows with templated prompts. - -**Structure:** -``` -src/interactions/my-interaction/ -└── my_workflow/ - ├── prompt.jst # JavaScript template string - └── index.ts # Interaction spec -``` - -**Example: prompt.jst** -```javascript -return ` -# Task: ${taskName} - -## Parameters -- **Input**: ${input} -- **Options**: ${JSON.stringify(options)} - -## Instructions - -Please process the following according to the parameters above. - -${additionalInstructions || 'No additional instructions.'} -`; -``` - -**Example: index.ts** -```typescript -import { PromptRole } from "@llumiverse/common"; -import { InteractionSpec, TemplateType } from "@vertesia/common"; -import PROMPT_CONTENT from "./prompt.jst?raw"; - -export default { - name: "my_workflow", - title: "My Workflow", - description: "Description of what this interaction does", - result_schema: { - type: "object", - properties: { - result: { - type: "string", - description: "The workflow result" - } - }, - required: ["result"] - }, - prompts: [{ - role: PromptRole.user, - content: PROMPT_CONTENT, - content_type: TemplateType.jst, - schema: { - type: "object", - properties: { - taskName: { type: "string" }, - input: { type: "string" }, - options: { type: "object" }, - additionalInstructions: { type: "string" } - }, - required: ["taskName", "input"] - } - }], - tags: ["tag1", "tag2"] -} satisfies InteractionSpec; -``` - -**Register the collection:** - -Create `src/interactions/my-interaction/index.ts`: -```typescript -import { InteractionCollection } from "@vertesia/tools-sdk"; -import myWorkflow from "./my_workflow/index.js"; -import icon from "./icon.svg.js"; - -export const MyInteractions = new InteractionCollection({ - name: "my-interaction", - title: "My Interactions", - description: "Description of the interaction collection", - icon, - interactions: [myWorkflow] -}); -``` - -Add to `src/interactions/index.ts`: -```typescript -import { MyInteractions } from "./my-interaction/index.js"; - -export async function loadInteractions() { - return [ - MyInteractions, - // ... other collections - ]; -} -``` - -## API Reference - -### Endpoints - -#### `GET /api` -Returns descriptions of all available tools, skills, and interactions. - -**Response:** -```json -{ - "tools": [...], - "skills": [...], - "interactions": [...] -} -``` - -#### `POST /api` -Executes a tool with the provided payload. - -**Request Body:** -```json -{ - "tool_name": "calculator", - "tool_input": { - "expression": "2 + 2" - }, - "context": { - "serverUrl": "http://localhost:5174", - "storeUrl": "http://store.example.com", - "apikey": "your-api-key" - }, - "vars": {} -} -``` - -**Response:** -```json -{ - "is_error": false, - "content": "Result: 2 + 2 = 4" -} -``` - -## Deployment - -The template supports **two deployment modes**: - -### 1. Vercel Functions (Serverless) - -Best for: Auto-scaling, zero-config deployment - -```bash -# Install Vercel CLI -npm i -g vercel - -# Deploy -vercel -``` - -The `api/index.js` adapter automatically converts your Hono server to Vercel Functions format. - -### 2. Node.js HTTP Server - -Best for: Cloud Run, Railway, Fly.io, Docker, VPS - -The template includes `src/server-node.ts` which creates a standalone HTTP server. - -**Deploy to Cloud Run:** -```bash -gcloud run deploy tool-server \ - --source . \ - --platform managed \ - --region us-central1 -``` - -**Deploy to Railway:** -1. Connect your repo -2. Railway auto-detects Node.js -3. Uses `npm start` automatically - -**Deploy to Docker:** -```dockerfile -FROM node:24-alpine -WORKDIR /app -COPY package*.json ./ -RUN npm install --production -COPY . . -RUN npm run build -EXPOSE 3000 -CMD ["npm", "start"] -``` - -**Deploy to VPS:** -```bash -# On your server -git clone -cd tool-server-template -npm install -npm run build -npm start - -# Or use PM2 for process management -npm i -g pm2 -pm2 start lib/server-node.js --name tool-server -``` - -### Other Platforms - -Hono's flexibility allows deployment to: -- Cloudflare Workers -- Deno Deploy -- AWS Lambda (with adapter) -- Bun -- Azure Functions - -See [Hono documentation](https://hono.dev/getting-started/basic) for platform-specific guides. - -## Configuration - -### Customization - -Edit `src/server.ts` to customize: - -```typescript -const server = createToolServer({ - title: 'Your Server Name', - description: 'Your server description', - prefix: '/api', - tools, - interactions, - skills, - mcpProviders: [] // Add MCP providers here -}); -``` - -### Environment Variables - -For GitHub integration or other sensitive config, use environment variables: - -1. Create `.env` file: -```bash -GITHUB_APP_ID=your-app-id -GITHUB_APP_PRIVATE_KEY_FILE=path/to/key.pem -``` - -2. Access in code: -```typescript -const githubAppId = process.env.GITHUB_APP_ID; -``` - -## Browser Bundles - -After building, browser-ready bundles are available at: -``` -dist/libs/tool-server-{collection-name}.js -``` - -Use them in the browser: -```html - -``` - -## Debugging - -This template includes full VSCode debugging support with breakpoints, watch expressions, and call stack inspection. - -**Quick start:** -1. Press **F5** in VSCode -2. Select "Debug Server" -3. Set breakpoints in your TypeScript files -4. Make API requests to trigger breakpoints - -For complete debugging workflows, configurations, and troubleshooting, see **[.vscode/README.md](.vscode/README.md)**. - -## Development Tips - -1. **Watch Mode**: `npm run dev` automatically rebuilds and restarts on file changes -2. **Type Safety**: Use `satisfies` to ensure type correctness while preserving inference -3. **Raw Imports**: Use `import content from './file.jst?raw'` for large template strings -4. **Debugging**: See [.vscode/README.md](.vscode/README.md) for VSCode debugging setup -5. **Testing Tools**: Use `POST /api` with curl or Postman to test tools - -### Testing with curl - -**Test a tool:** -```bash -curl -H "Authorization: Bearer {{VERTESIA_JWT}}" \ - -H "Content-Type: application/json" \ - -X POST "http://localhost:3000/api/tools/calculator" \ - -d '{ - "tool_use": { - "id": "run1", - "tool_name": "calculator", - "tool_input": {"expression": "10 * 5"} - } - }' -``` - -**Get interaction details:** -```bash -curl -H "Authorization: Bearer {{VERTESIA_JWT}}" \ - "http://localhost:3000/api/interactions/summarize/text_summarizer" -``` - -**Get skill details:** -```bash -curl -H "Authorization: Bearer {{VERTESIA_JWT}}" \ - "http://localhost:3000/api/skills/code-review/skill_code-review" -``` - -Replace `{{VERTESIA_JWT}}` with a valid Vertesia JWT token. - -## Troubleshooting - -### Build fails with module errors -- Ensure all imports use `.js` extensions (ESM requirement) -- Check that `tsconfig.json` has `"module": "ES2022"` -- Verify `@rollup/plugin-typescript` is installed - -### Dev server not starting -- Make sure `concurrently` is installed -- Check that dependencies are installed (`npm install`) -- If still having issues, try `npm run build` manually first - -### Tool execution errors -- Check tool implementation returns a string -- Verify input_schema matches the parameters -- Check console for detailed error messages - -## License - -MIT - -## Contributing - -Contributions are welcome! Please feel free to submit issues or pull requests. - ---- - -Built with ❤️ using [Hono](https://hono.dev/) and [@vertesia/tools-sdk](https://github.com/vertesia/tools-sdk) diff --git a/templates/tool-server-template/TESTING.md b/templates/tool-server-template/TESTING.md deleted file mode 100644 index 85ac4ae28..000000000 --- a/templates/tool-server-template/TESTING.md +++ /dev/null @@ -1,480 +0,0 @@ -# Testing Guide - -This guide shows you how to test your tool server locally before deploying. - -## Prerequisites - -Install dependencies: -```bash -pnpm install -# or -npm install -``` - -Note: The `dev` command automatically builds on first run, so you can skip the manual build step unless you specifically need production builds. - -## Testing Methods - -The template uses **Node.js HTTP server** for both development and production - ensuring you test the exact same code that runs in production (Cloud Run, Railway, etc.). - -### Development Mode (with auto-rebuild) - -This mode watches for file changes and automatically rebuilds and restarts the server: - -```bash -pnpm dev -# or -npm run dev -``` - -**What happens:** -- Terminal 1: Rollup watch mode rebuilds TypeScript on file changes -- Terminal 2: Node.js --watch restarts server when lib/ changes -- Both run via `concurrently` in a single command - -**Server runs on:** http://localhost:3000 (default) - -**Test the API:** -```bash -# List all tools/skills/interactions -curl http://localhost:3000/api - -# Execute calculator tool -curl -X POST http://localhost:3000/api \ - -H "Content-Type: application/json" \ - -d '{ - "tool_name": "calculator", - "tool_input": {"expression": "10 * 5 + 2^3"}, - "context": {"serverUrl": "http://localhost:3000"} - }' -``` - -**Browse the UI:** -- Main page: http://localhost:3000/ -- Calculator tool: http://localhost:3000/tools/calculator -- Code review skill: http://localhost:3000/skills/code-review -- Summarize interaction: http://localhost:3000/interactions/summarize - -**Custom port:** -```bash -PORT=8080 pnpm dev -``` - -### Production Mode - -This is identical to how it runs in Cloud Run, Railway, etc. - -**Step 1: Build** -```bash -pnpm build -# or -npm run build -``` - -**Step 2: Start the server** -```bash -pnpm start -# or -npm start -``` - -Server will start on **http://localhost:3000** (default) - -### Manual Control (Advanced) - -If you want separate control of build and server processes: - -```bash -# Terminal 1: Rollup watch mode -pnpm run build:watch - -# Terminal 2: Node with auto-restart -pnpm run start:watch -``` - -## Testing Individual Resources - -### 1. Testing the Calculator Tool - -Replace `{{VERTESIA_JWT}}` with a valid Vertesia JWT token in all examples below. - -**Basic calculation:** -```bash -curl -H "Authorization: Bearer {{VERTESIA_JWT}}" \ - -H "Content-Type: application/json" \ - -X POST "http://localhost:3000/api/tools/calculator" \ - -d '{ - "tool_use": { - "id": "run1", - "tool_name": "calculator", - "tool_input": {"expression": "2 + 2"} - } - }' -``` - -Expected response: -```json -{ - "is_error": false, - "content": "Result: 2 + 2 = 4" -} -``` - -**Complex expression:** -```bash -curl -H "Authorization: Bearer {{VERTESIA_JWT}}" \ - -H "Content-Type: application/json" \ - -X POST "http://localhost:3000/api/tools/calculator" \ - -d '{ - "tool_use": { - "id": "run2", - "tool_name": "calculator", - "tool_input": {"expression": "(10 + 5) * 2 - 3^2"} - } - }' -``` - -**Error handling:** -```bash -curl -H "Authorization: Bearer {{VERTESIA_JWT}}" \ - -H "Content-Type: application/json" \ - -X POST "http://localhost:3000/api/tools/calculator" \ - -d '{ - "tool_use": { - "id": "run3", - "tool_name": "calculator", - "tool_input": {"expression": "invalid"} - } - }' -``` - -Expected response: -```json -{ - "is_error": true, - "content": "Calculation error: Failed to evaluate expression: ..." -} -``` - -### 2. Testing Skills - -Skills are prompt templates, so you can't "execute" them via the API. Instead, retrieve their details: - -**Get skill details:** -```bash -curl -H "Authorization: Bearer {{VERTESIA_JWT}}" \ - "http://localhost:3000/api/skills/code-review/skill_code-review" -``` - -**List all skills:** -```bash -curl -H "Authorization: Bearer {{VERTESIA_JWT}}" \ - "http://localhost:3000/api" | jq '.skills' -``` - -**View skill in browser:** -- http://localhost:3000/skills/code-review - -### 3. Testing Interactions - -Interactions define workflows with templated prompts. - -**Get interaction details:** -```bash -curl -H "Authorization: Bearer {{VERTESIA_JWT}}" \ - "http://localhost:3000/api/interactions/summarize/text_summarizer" -``` - -**List all interactions:** -```bash -curl -H "Authorization: Bearer {{VERTESIA_JWT}}" \ - "http://localhost:3000/api" | jq '.interactions' -``` - -**Browse interaction page:** -- http://localhost:3000/interactions/summarize - -## Testing Vercel Deployment Locally - -If you want to test the Vercel deployment setup locally: - -```bash -# Install Vercel CLI -npm i -g vercel - -# Run Vercel dev server -pnpm start:vercel -# or -vercel dev -``` - -This simulates the Vercel serverless function environment. - -## Common Test Scenarios - -### Test 1: Server Health Check -```bash -# Should return 200 with list of tools/skills/interactions -curl -i http://localhost:3000/api -``` - -### Test 2: Invalid Tool Name -```bash -curl -H "Authorization: Bearer {{VERTESIA_JWT}}" \ - -H "Content-Type: application/json" \ - -X POST "http://localhost:3000/api/tools/nonexistent" \ - -d '{ - "tool_use": { - "id": "test1", - "tool_name": "nonexistent", - "tool_input": {} - } - }' -``` - -Should return an error response. - -### Test 3: Missing Required Parameters -```bash -curl -H "Authorization: Bearer {{VERTESIA_JWT}}" \ - -H "Content-Type: application/json" \ - -X POST "http://localhost:3000/api/tools/calculator" \ - -d '{ - "tool_use": { - "id": "test2", - "tool_name": "calculator", - "tool_input": {} - } - }' -``` - -Should return validation error about missing `expression`. - -### Test 4: HTML Pages Generated -```bash -# Check main index page exists -curl -s http://localhost:3000/ | grep -q "Tool Server Template" -echo $? # Should output 0 (success) - -# Check calculator tool page -curl -s http://localhost:3000/tools/calculator | grep -q "Calculator Tools" -echo $? # Should output 0 -``` - -### Test 5: Browser Bundles Created -```bash -# Check that browser bundle exists -ls -lh dist/libs/tool-server-calculator.js - -# Should show the bundled file with size -``` - -## Debugging Tips - -### Enable Detailed Logging - -Add to your `.env` file: -```bash -DEBUG=* -NODE_ENV=development -``` - -### Check Build Output - -After building, verify structure: -```bash -tree lib/ -L 3 -``` - -Expected structure: -``` -lib/ -├── server.js -├── server-node.js -├── build-site.js -├── index.html -├── tools/ -│ └── calculator/ -│ ├── index.html -│ ├── index.js -│ └── ... -├── skills/ -│ └── code-review/ -│ ├── index.html -│ └── SKILL.md -├── interactions/ -│ └── summarize/ -│ └── ... -└── libs/ - └── tool-server-calculator.js -``` - -### Check TypeScript Compilation - -Verify no TypeScript errors: -```bash -pnpm exec tsc --noEmit -``` - -### Watch Rollup Build Process - -See what Rollup is doing: -```bash -pnpm run build:watch -# Watch the console for build errors -``` - -### Test with Different Ports - -```bash -# Test port binding -PORT=8080 pnpm start -PORT=9000 pnpm start -``` - -### Debug Raw Imports - -If `?raw` imports aren't working, check: -```bash -# Verify the rawPlugin is in rollup.config.js -grep -A 10 "rawPlugin" rollup.config.js - -# Check the imported file exists -ls -la src/interactions/*/prompts/*.jst -``` - -## Performance Testing - -### Simple Load Test with Apache Bench - -```bash -# Install apache bench (usually pre-installed on Mac/Linux) -# On Mac: already available -# On Ubuntu: apt-get install apache2-utils - -# Test GET /api endpoint -ab -n 1000 -c 10 http://localhost:3000/api - -# Test POST /api endpoint (save request to file first) -cat > post_data.json < { - const dir = path.join(libToolCollectionsDir, name); - return ( - fs.statSync(dir).isDirectory() && - fs.existsSync(path.join(dir, 'index.js')) - ); - }) - : []; - -// Create a bundle configuration for each tool collection -const browserBundles = entries.map((name) => ({ - input: path.join(libToolCollectionsDir, name, 'index.js'), - output: { - file: path.join(outputDir, `tool-server-${name}.js`), - format: 'es', - sourcemap: true, - inlineDynamicImports: true - }, - plugins: [ - nodeResolve({ - browser: true, - preferBuiltins: false - }), - json(), - commonjs(), - terser({ - compress: { - drop_console: false - } - }) - ] -})); - -export default browserBundles; diff --git a/templates/tool-server-template/rollup.config.js b/templates/tool-server-template/rollup.config.js deleted file mode 100644 index 2b75fb0af..000000000 --- a/templates/tool-server-template/rollup.config.js +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Rollup Configuration for Server Build - * - * This configuration handles: - * 1. TypeScript compilation (src → lib) with preserveModules - * 2. Import transformations via @vertesia/build-tools - * - Raw file imports (?raw) for template files - * - Skill imports (?skill) for markdown skill definitions - * - * Browser bundles are in rollup.config.browser.js - */ -import json from '@rollup/plugin-json'; -import typescript from '@rollup/plugin-typescript'; -import { vertesiaImportPlugin, skillTransformer, rawTransformer, skillCollectionTransformer, promptTransformer } from '@vertesia/build-tools'; - -// ============================================================================ -// Exit Plugin - Forces process exit after build completes -// This prevents TypeScript plugin from keeping the process alive -// ============================================================================ -function exitPlugin() { - return { - name: 'force-exit', - closeBundle() { - console.log('Tool server build Done'); - // Force exit after bundle completes to prevent hanging - setImmediate(() => process.exit(0)); - } - }; -} - -// ============================================================================ -// Server Build Configuration (TypeScript → JavaScript) -// ============================================================================ -const serverBuild = { - input: { - server: './src/server.ts', - 'server-node': './src/server-node.ts', - 'build-site': './src/build-site.ts' - }, - output: { - dir: 'lib', - format: 'es', - sourcemap: true, - preserveModules: true, - preserveModulesRoot: 'src', - entryFileNames: '[name].js' - }, - external: (id) => { - // Keep relative imports as part of the bundle - if (id.startsWith('.') || id.startsWith('/')) { - return false; - } - // Externalize all node modules and absolute imports - return true; - }, - plugins: [ - vertesiaImportPlugin({ - transformers: [ - skillTransformer, // Handles .md?skill imports - skillCollectionTransformer, // Handles .?skills imports - promptTransformer, // Handles ?prompt imports - rawTransformer // Handles ?raw imports - ], - assetsDir: './dist', - widgetConfig: { - minify: false, - tsconfig: './tsconfig.widgets.json' - } - - }), - typescript({ - tsconfig: './tsconfig.json', - declaration: true, - declarationDir: 'lib', - sourceMap: true - }), - json(), - exitPlugin() - ] -}; - -// ============================================================================ -// Export server build configuration only -// ============================================================================ -export default serverBuild; diff --git a/templates/tool-server-template/rollup.config.widgets.js b/templates/tool-server-template/rollup.config.widgets.js deleted file mode 100644 index 98315235a..000000000 --- a/templates/tool-server-template/rollup.config.widgets.js +++ /dev/null @@ -1,109 +0,0 @@ -/** - * Rollup Configuration for Widget Bundles - * - * This configuration: - * 1. Finds all .tsx files in src/skills/ directories (next to SKILL.md files) - * 2. Bundles each widget with all dependencies included - * 3. Outputs browser-ready ES modules flat to dist/widgets/ - * - * Output: dist/widgets/{widget-name}.js - */ -import commonjs from '@rollup/plugin-commonjs'; -import { nodeResolve } from '@rollup/plugin-node-resolve'; -import terser from '@rollup/plugin-terser'; -import typescript from '@rollup/plugin-typescript'; -import { globSync } from 'fs'; -import path from 'path'; - -const outputDir = './dist/widgets'; - -/** - * Find all .tsx files in skill directories using glob. - * Widgets are located next to SKILL.md files in skill directories. - * - * Structure: - * src/skills/ - * examples/ - * user-select/ - * SKILL.md ← Skill definition - * user-select.tsx ← Widget entry point - */ -function findWidgetEntryPoints() { - // Use glob to find all .tsx files in skill directories - const files = globSync('src/skills/**/*.tsx'); - - const widgets = files.map(file => ({ - name: path.basename(file, '.tsx'), - path: file - })); - - // Check for duplicate widget names - const nameMap = new Map(); - for (const widget of widgets) { - if (nameMap.has(widget.name)) { - const existing = nameMap.get(widget.name); - throw new Error( - `Duplicate widget name "${widget.name}" found:\n` + - ` - ${existing}\n` + - ` - ${widget.path}\n` + - `Widget names must be unique across all skills.` - ); - } - nameMap.set(widget.name, widget.path); - } - - return widgets; -} - -// Find all widget entry points -const widgets = findWidgetEntryPoints(); - -console.log(`Found ${widgets.length} widget(s):`, widgets.map(w => w.name).join(', ')); - -// Create a bundle configuration for each widget -const widgetBundles = widgets.map(({ name, path: widgetPath }) => ({ - input: widgetPath, - output: { - dir: outputDir, - entryFileNames: `${name}.js`, - format: 'es', - sourcemap: true, - inlineDynamicImports: true - }, - external: [ - // Externalize React dependencies - they should be provided by the host application - 'react', - 'react-dom', - 'react/jsx-runtime', - 'react/jsx-dev-runtime', - 'react-dom/client' - ], - plugins: [ - typescript({ - tsconfig: './tsconfig.widgets.json', - declaration: false, - sourceMap: true - }), - nodeResolve({ - browser: true, - preferBuiltins: false, - extensions: ['.tsx', '.ts', '.jsx', '.js'] - }), - commonjs(), - terser({ - compress: { - drop_console: false - } - }) - ] -})); - -// Rollup requires at least one config, so export empty config if no widgets found -export default widgetBundles.length > 0 ? widgetBundles : { - input: 'src/widgets/index.ts', // Dummy input - output: { - file: '/dev/null', // No output - format: 'es' - }, - plugins: [] -}; \ No newline at end of file diff --git a/templates/tool-server-template/src/build-site.ts b/templates/tool-server-template/src/build-site.ts deleted file mode 100644 index bba7f22e2..000000000 --- a/templates/tool-server-template/src/build-site.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { - indexPage, - interactionCollectionPage, - skillCollectionPage, - toolCollectionPage -} from "@vertesia/tools-sdk"; -import { copyFileSync, mkdirSync, writeFileSync } from "node:fs"; -import { glob } from "node:fs/promises"; -import { basename } from "node:path"; -import { ServerConfig } from "./config.js"; -import { skills } from "./skills/index.js"; -import { tools } from "./tools/index.js"; - -/** - * Generates static HTML pages for all tool collections, skills, and interactions - * This runs during the build process to create browsable documentation - */ -async function build(outDir: string) { - console.log(`Building static site to ${outDir}...`); - - // Ensure output directory exists - mkdirSync(outDir, { recursive: true }); - - // Load interactions - const interactions = ServerConfig.interactions; - - // Create main index page - console.log('Creating index page...'); - writeFile( - `${outDir}/index.html`, - indexPage(ServerConfig) - ); - - // Create pages for each tool collection - console.log(`Creating ${tools.length} tool collection pages...`); - for (const coll of tools) { - const dir = `${outDir}/tools/${coll.name}`; - mkdirSync(dir, { recursive: true }); - writeFile( - `${dir}/index.html`, - toolCollectionPage(coll) - ); - } - - // Create pages for each skill collection - console.log(`Creating ${skills.length} skill collection pages...`); - for (const coll of skills) { - const dir = `${outDir}/skills/${coll.name}`; - mkdirSync(dir, { recursive: true }); - writeFile( - `${dir}/index.html`, - skillCollectionPage(coll) - ); - } - - // Create pages for each interaction collection - console.log(`Creating ${interactions.length} interaction collection pages...`); - for (const coll of interactions) { - const dir = `${outDir}/interactions/${coll.name}`; - mkdirSync(dir, { recursive: true }); - writeFile( - `${dir}/index.html`, - interactionCollectionPage(coll) - ); - } - - console.log('✓ Static site build complete!'); -} - -/** - * Find and copy all scripts (.js, .py) from skill directories to dist/scripts (flat) - * Uses glob to find: src/skills/*-slash-*-slash-*.{py,js} - */ -async function copyScriptsFromSkills(outputDir: string): Promise { - // Ensure output directory exists - mkdirSync(outputDir, { recursive: true }); - - // Find all .py and .js files in skill directories - const scriptFiles = glob('src/skills/*/*/*.{py,js}'); - - // Check for duplicate script names - const nameMap = new Map(); - const filesToCopy: { file: string; name: string }[] = []; - - for await (const file of scriptFiles) { - const name = basename(file); - if (nameMap.has(name)) { - const existing = nameMap.get(name)!; - throw new Error( - `Duplicate script name "${name}" found:\n` + - ` - ${existing}\n` + - ` - ${file}\n` + - `Script names must be unique across all skills.` - ); - } - nameMap.set(name, file); - filesToCopy.push({ file, name }); - } - - // Copy all scripts - for (const { file, name } of filesToCopy) { - const destPath = `${outputDir}/${name}`; - copyFileSync(file, destPath); - } - - return filesToCopy.length; -} - -function writeFile(file: string, content: string) { - writeFileSync(file, content.trim() + '\n', "utf8"); -} - -// Run the build -const outDir = process.argv[2] || './dist'; -build(outDir) - .then(() => { - process.exit(0); - }) - .catch(error => { - console.error('Build failed:', error); - process.exit(1); - }); diff --git a/templates/tool-server-template/src/config.ts b/templates/tool-server-template/src/config.ts deleted file mode 100644 index bb5d1c1d1..000000000 --- a/templates/tool-server-template/src/config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { ToolServerConfig } from "@vertesia/tools-sdk"; -import { interactions } from "./interactions/index.js"; -import { mcpProviders } from "./mcp/index.js"; -import { skills } from "./skills/index.js"; -import { tools } from "./tools/index.js"; - -const CONFIG__SERVER_TITLE = "Tool Server Template"; -export const ServerConfig = { - hideUILinks: true, - disableHtml: true, - title: CONFIG__SERVER_TITLE, - prefix: '/api', - tools, - interactions, - skills, - mcpProviders -} satisfies ToolServerConfig; diff --git a/templates/tool-server-template/src/imports.d.ts b/templates/tool-server-template/src/imports.d.ts deleted file mode 100644 index 251767391..000000000 --- a/templates/tool-server-template/src/imports.d.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * TypeScript declarations for special import types - */ - -// Skill imports - markdown files with ?skill suffix -declare module '*.md?skill' { - import type { SkillDefinition } from '@vertesia/build-tools'; - const skill: SkillDefinition; - export default skill; -} - -// Skill imports - markdown files with ?skill suffix -declare module '*/SKILL.md' { - import type { SkillDefinition } from '@vertesia/build-tools'; - const skill: SkillDefinition; - export default skill; -} - -// Skill collection imports - any file with ?skills suffix -declare module '*?skills' { - import type { SkillDefinition } from '@vertesia/build-tools'; - const skills: SkillDefinition[]; - export default skills; -} - -// Raw imports - any file with ?raw suffix -declare module '*?raw' { - const content: string; - export default content; -} - -// Prompt imports - template files with ?prompt suffix -declare module '*?prompt' { - import type { PromptDefinition } from '@vertesia/build-tools'; - const prompt: PromptDefinition; - export default prompt; -} diff --git a/templates/tool-server-template/src/interactions/examples/icon.svg.ts b/templates/tool-server-template/src/interactions/examples/icon.svg.ts deleted file mode 100644 index 73809688f..000000000 --- a/templates/tool-server-template/src/interactions/examples/icon.svg.ts +++ /dev/null @@ -1,7 +0,0 @@ -export default ` - - - - - -`; diff --git a/templates/tool-server-template/src/interactions/examples/index.ts b/templates/tool-server-template/src/interactions/examples/index.ts deleted file mode 100644 index 0ee7f8a55..000000000 --- a/templates/tool-server-template/src/interactions/examples/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { InteractionCollection } from "@vertesia/tools-sdk"; -import whatColor from "./what_color/index.js"; -import icon from "./icon.svg.js"; - -export const ExampleInteractions = new InteractionCollection({ - name: "examples", - title: "Example Interactions", - description: "A collection of interaction examples", - icon, - interactions: [whatColor] -}); diff --git a/templates/tool-server-template/src/interactions/examples/what_color/index.ts b/templates/tool-server-template/src/interactions/examples/what_color/index.ts deleted file mode 100644 index c2e7972ff..000000000 --- a/templates/tool-server-template/src/interactions/examples/what_color/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { InteractionSpec } from "@vertesia/common"; -import PROMPT from "./prompt.hbs?prompt"; -import result_schema from "./result_schema"; - -export default { - name: "what_color", - title: "What Color", - description: "Identifies the color of a specified object.", - result_schema, - prompts: [PROMPT], - tags: ["text", "summarization", "nlp", "content"] -} satisfies InteractionSpec; diff --git a/templates/tool-server-template/src/interactions/examples/what_color/prompt.hbs b/templates/tool-server-template/src/interactions/examples/what_color/prompt.hbs deleted file mode 100644 index 6192e23c8..000000000 --- a/templates/tool-server-template/src/interactions/examples/what_color/prompt.hbs +++ /dev/null @@ -1,6 +0,0 @@ ---- -role: user -content_type: handlebars -schema: ./prompt_schema.ts ---- -What color is {{object}}? diff --git a/templates/tool-server-template/src/interactions/examples/what_color/prompt_schema.ts b/templates/tool-server-template/src/interactions/examples/what_color/prompt_schema.ts deleted file mode 100644 index f50eac210..000000000 --- a/templates/tool-server-template/src/interactions/examples/what_color/prompt_schema.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { JSONSchema } from "@llumiverse/common"; - -export default { - type: "object", - properties: { - object: { - type: "string", - description: "The object to identify the color of" - }, - }, - required: ["object"] -} satisfies JSONSchema; diff --git a/templates/tool-server-template/src/interactions/examples/what_color/result_schema.ts b/templates/tool-server-template/src/interactions/examples/what_color/result_schema.ts deleted file mode 100644 index 11fd37d49..000000000 --- a/templates/tool-server-template/src/interactions/examples/what_color/result_schema.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { JSONSchema } from "@llumiverse/common"; - -export default { - type: "object", - properties: { - color: { - type: "string", - description: "The identified color of the object" - }, - object: { - type: "string", - description: "The object whose color was identified" - } - }, - required: ["color", "object"] -} satisfies JSONSchema; diff --git a/templates/tool-server-template/src/interactions/index.ts b/templates/tool-server-template/src/interactions/index.ts deleted file mode 100644 index 8677d352a..000000000 --- a/templates/tool-server-template/src/interactions/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { ExampleInteractions } from "./examples/index.js"; - -export const interactions = [ - ExampleInteractions -]; diff --git a/templates/tool-server-template/src/mcp/MCPProvider.ts b/templates/tool-server-template/src/mcp/MCPProvider.ts deleted file mode 100644 index 1420c0a6b..000000000 --- a/templates/tool-server-template/src/mcp/MCPProvider.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { MCPConnectionDetails } from "@vertesia/tools-sdk"; -import { AuthSession } from "@vertesia/tools-sdk"; - - -export abstract class MCPProvider { - name: string; - description?: string; - constructor(name: string, description?: string) { - this.name = name; - this.description = description; - } - - /** - * Generate an authorization token to access the returned mcp server URL. the mcp server URL is returned in the generation payload since - * it may change depending on the session and config vars. - * @param session - * @param config - */ - abstract createMCPConnection(session: AuthSession, config: Record): Promise; - -} - - diff --git a/templates/tool-server-template/src/mcp/index.ts b/templates/tool-server-template/src/mcp/index.ts deleted file mode 100644 index e3da0a0b8..000000000 --- a/templates/tool-server-template/src/mcp/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { MCPProvider } from "./MCPProvider.js"; - -export const mcpProviders: MCPProvider[] = [ -] diff --git a/templates/tool-server-template/src/server-node.ts b/templates/tool-server-template/src/server-node.ts deleted file mode 100644 index 406135c7e..000000000 --- a/templates/tool-server-template/src/server-node.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Node.js HTTP Server Entry Point - * - * This file starts a standalone Node.js HTTP server using @hono/node-server. - * Use this for: - * - Local development and testing - * - Deploying to Cloud Run, Railway, Fly.io, etc. - * - Running in Docker containers - */ -import { serve } from '@hono/node-server'; -import { serveStatic } from '@hono/node-server/serve-static'; -import server from './server.js'; -import type { Context, Next } from 'hono'; - -const port = parseInt(process.env.PORT || '3000', 10); - -console.log(`Starting Tool Server on port ${port}...`); -console.log(`API endpoint: http://localhost:${port}/api`); -console.log(`Web UI: http://localhost:${port}/`); - -// Add static file serving for widgets, scripts, and other assets -const staticFile = serveStatic({ root: './dist' }); -server.all("*", async (c: Context, next: Next) => { - // Serve static resources from dist/ - return staticFile(c, next); -}); - -serve({ - fetch: server.fetch, - port -}, (info) => { - console.log(`✓ Server is running at http://localhost:${info.port}`); -}); diff --git a/templates/tool-server-template/src/server.ts b/templates/tool-server-template/src/server.ts deleted file mode 100644 index 42efc41cd..000000000 --- a/templates/tool-server-template/src/server.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createToolServer } from "@vertesia/tools-sdk"; -import { ServerConfig } from "./config.js"; - -// Create server using tools-sdk -const server = createToolServer(ServerConfig); - -export default server; diff --git a/templates/tool-server-template/src/skills/examples/index.ts b/templates/tool-server-template/src/skills/examples/index.ts deleted file mode 100644 index 53ca8568f..000000000 --- a/templates/tool-server-template/src/skills/examples/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { SkillCollection } from "@vertesia/tools-sdk"; -import skills from "./all?skills"; - -export const ExampleSkills = new SkillCollection({ - name: "examples", - title: "Example Skills", - description: "Example skills demonstrating various functionalities", - skills -}); diff --git a/templates/tool-server-template/src/skills/examples/user-select/SKILL.md b/templates/tool-server-template/src/skills/examples/user-select/SKILL.md deleted file mode 100644 index 203945d78..000000000 --- a/templates/tool-server-template/src/skills/examples/user-select/SKILL.md +++ /dev/null @@ -1,110 +0,0 @@ ---- -name: user-select -title: User Selection -description: Present users with choices and collect their selections for interactive decision-making -keywords: [ select, choose, option, pick, decision, choice ] ---- - -# User Selection - -You are a user selection generator. Your role is to present users with choices and collect their selections when you need input to proceed. - -## Output Format - -When you need the user to select from options, output a code block with the `user-select` language identifier: - -```user-select -{ - "options": [ - {"text": "Option 1 display text", "value": "option1"}, - {"text": "Option 2 display text", "value": "option2"}, - {"text": "Option 3 display text", "value": "option3"} - ], - "multiple": false -} -``` - -## Field Specifications - -- **options** (required): Array of option objects, each containing: - - **text** (required): The display text shown to the user - - **value** (required): The value returned when this option is selected -- **multiple** (optional): Boolean, defaults to false. Set to true to allow multiple selections - -## Guidelines - -1. **Option Design** - - Keep option text clear and concise (1-8 words) - - Use meaningful values that represent the choice - - Provide 2-10 options for best usability - - Ensure text and values are unique within the options array - -2. **Single vs Multiple Selection** - - Use single selection (multiple: false or omit) for mutually exclusive choices - - Use multiple selection (multiple: true) when users can choose several options - - Single selection is the default behavior - -3. **When to Use** - - When you need user input to determine next steps - - To let users choose between different approaches or solutions - - To configure settings or preferences - - To select from a predefined list of items or actions - - When binary yes/no is insufficient and you need more nuanced choices - -## Examples - -### Simple Single Choice -```user-select -{ - "options": [ - {"text": "Continue with deployment", "value": "deploy"}, - {"text": "Review changes first", "value": "review"}, - {"text": "Cancel operation", "value": "cancel"} - ] -} -``` - -### Multiple Selection -```user-select -{ - "options": [ - {"text": "Generate unit tests", "value": "unit-tests"}, - {"text": "Generate integration tests", "value": "integration-tests"}, - {"text": "Add documentation", "value": "docs"}, - {"text": "Update README", "value": "readme"} - ], - "multiple": true -} -``` - -### Environment Selection -```user-select -{ - "options": [ - {"text": "Development environment", "value": "dev"}, - {"text": "Staging environment", "value": "staging"}, - {"text": "Production environment", "value": "prod"} - ] -} -``` - -### Feature Selection -```user-select -{ - "options": [ - {"text": "Dark mode support", "value": "dark-mode"}, - {"text": "Offline functionality", "value": "offline"}, - {"text": "Push notifications", "value": "notifications"}, - {"text": "Analytics integration", "value": "analytics"} - ], - "multiple": true -} -``` - -## Usage Tips - -- Always provide clear, descriptive text for each option -- Use values that make sense programmatically (kebab-case or snake_case recommended) -- Consider the context when deciding single vs multiple selection -- After the user makes a selection, acknowledge their choice and proceed accordingly -- Don't overuse - only present selections when user input is genuinely needed diff --git a/templates/tool-server-template/src/skills/examples/user-select/user-select.tsx b/templates/tool-server-template/src/skills/examples/user-select/user-select.tsx deleted file mode 100644 index 0f92dabd3..000000000 --- a/templates/tool-server-template/src/skills/examples/user-select/user-select.tsx +++ /dev/null @@ -1,245 +0,0 @@ -import React from 'react'; - -const { useState } = React; - -/** - * Option structure for user selection - */ -interface SelectOption { - text: string; - value: string; -} - -/** - * User selection data structure - */ -interface UserSelectData { - options: SelectOption[]; - multiple?: boolean; -} - -/** - * Props for the UserSelectWidget component - */ -interface UserSelectWidgetProps { - /** - * The selection data as a JSON string or parsed object - */ - code: string | UserSelectData; -} - -/** - * Interactive user selection widget component - * - * This widget renders selection options for users to choose from. - * It supports both single and multiple selection modes. - */ -export default function UserSelectWidget(props: UserSelectWidgetProps) { - const [selected, setSelected] = useState>(new Set()); - const [submitted, setSubmitted] = useState(false); - - // Parse the data - handle both string and object inputs - let data: UserSelectData; - try { - if (typeof props.code === 'string') { - data = JSON.parse(props.code); - } else { - data = props.code; - } - } catch (error) { - return ( -
-

Error: Invalid selection data

-

Failed to parse selection JSON

-
- ); - } - - // Validate data structure - if (!data.options || !Array.isArray(data.options) || data.options.length === 0) { - return ( -
-

Error: Invalid selection structure

-

- Selection must have at least one option -

-
- ); - } - - // Validate each option has text and value - const invalidOption = data.options.find(opt => !opt.text || !opt.value); - if (invalidOption) { - return ( -
-

Error: Invalid option format

-

- Each option must have 'text' and 'value' fields -

-
- ); - } - - const isMultiple = data.multiple === true; - - const handleOptionClick = (value: string) => { - if (submitted) return; - - const newSelected = new Set(selected); - - if (isMultiple) { - // Multi-select: toggle the option - if (newSelected.has(value)) { - newSelected.delete(value); - } else { - newSelected.add(value); - } - } else { - // Single select: replace the selection - newSelected.clear(); - newSelected.add(value); - } - - setSelected(newSelected); - }; - - const handleSubmit = () => { - if (selected.size === 0) return; - setSubmitted(true); - - // Format the selected values for display - const selectedOptions = data.options.filter(opt => selected.has(opt.value)); - console.log('User selected:', isMultiple ? Array.from(selected) : Array.from(selected)[0]); - console.log('Selected options:', selectedOptions); - }; - - const handleReset = () => { - setSelected(new Set()); - setSubmitted(false); - }; - - return ( -
- {/* Header */} -
-

- {isMultiple ? 'Select Options' : 'Select an Option'} -

- {isMultiple && !submitted && ( -

- ℹ️ You can select multiple options -

- )} -
- - {/* Options */} -
- {data.options.map((option, index) => { - const isSelected = selected.has(option.value); - - return ( -
handleOptionClick(option.value)} - className={` - relative border rounded-lg p-3 transition-all - ${submitted - ? 'cursor-default' - : 'cursor-pointer hover:border-mixer-10 hover:bg-mixer-2' - } - ${isSelected && !submitted - ? 'border-info bg-info/10' - : 'border-mixer-5' - } - ${submitted && isSelected - ? 'border-success bg-success/10' - : '' - } - `} - > -
- {/* Selection indicator */} - {!submitted && ( -
- {isSelected && ( - - )} -
- )} - {submitted && isSelected && ( -
- -
- )} - - {option.text} - -
-
- ); - })} -
- - {/* Actions */} -
- {!submitted ? ( - <> - - {selected.size > 0 && ( - - )} - - ) : ( - <> -
- ✓ Selection confirmed -
- - - )} -
- - {/* Selected values display */} - {submitted && ( -
-

Selected value{isMultiple && selected.size > 1 ? 's' : ''}:

- - {isMultiple - ? JSON.stringify(Array.from(selected)) - : Array.from(selected)[0] - } - -
- )} -
- ); -} diff --git a/templates/tool-server-template/src/skills/index.ts b/templates/tool-server-template/src/skills/index.ts deleted file mode 100644 index cf59b98b7..000000000 --- a/templates/tool-server-template/src/skills/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { ExampleSkills } from "./examples"; - -export const skills = [ - ExampleSkills -]; diff --git a/templates/tool-server-template/src/tools/examples/calculator/calculator.ts b/templates/tool-server-template/src/tools/examples/calculator/calculator.ts deleted file mode 100644 index bc3acd6cb..000000000 --- a/templates/tool-server-template/src/tools/examples/calculator/calculator.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Tool, ToolExecutionContext, ToolExecutionPayload } from "@vertesia/tools-sdk"; -import { type CalculatorParams } from "./schema.js"; -import { ToolResultContent } from "@vertesia/common"; - - -/** - * Safely evaluates a mathematical expression - * Supports: +, -, *, /, ^, parentheses, and decimal numbers - */ -function evaluateExpression(expr: string): number { - // Remove whitespace - expr = expr.replace(/\s+/g, ''); - - // Replace ^ with ** for exponentiation - expr = expr.replace(/\^/g, '**'); - - // Validate expression - only allow numbers, operators, parentheses, and decimal points - if (!/^[0-9+\-*/.()^]+$/.test(expr)) { - throw new Error('Invalid expression. Only numbers and operators (+, -, *, /, ^) are allowed.'); - } - - // Use Function constructor for safe evaluation (better than eval) - try { - const result = new Function(`'use strict'; return (${expr})`)(); - if (typeof result !== 'number' || !isFinite(result)) { - throw new Error('Result is not a valid number'); - } - return result; - } catch (error) { - throw new Error(`Failed to evaluate expression: ${error instanceof Error ? error.message : 'Unknown error'}`); - } -} - -export async function calculate( - payload: ToolExecutionPayload, - _context: ToolExecutionContext -): Promise { - try { - const { expression } = payload.tool_use.tool_input!; - const result = evaluateExpression(expression); - - return { - is_error: false, - content: `Result: ${expression} = ${result}` - } satisfies ToolResultContent; - } catch (error) { - return { - is_error: true, - content: `Calculation error: ${error instanceof Error ? error.message : 'Unknown error'}` - } satisfies ToolResultContent; - } -} diff --git a/templates/tool-server-template/src/tools/examples/calculator/index.ts b/templates/tool-server-template/src/tools/examples/calculator/index.ts deleted file mode 100644 index fe238a99f..000000000 --- a/templates/tool-server-template/src/tools/examples/calculator/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Tool } from "@vertesia/tools-sdk"; -import { calculate } from "./calculator.js"; -import { CalculatorParams, Schema } from "./schema.js"; - -export const CalculatorTool = { - name: "calculator", - description: "Performs basic mathematical calculations. Supports addition (+), subtraction (-), multiplication (*), division (/), and exponentiation (^).", - input_schema: Schema, - run: calculate -} satisfies Tool; diff --git a/templates/tool-server-template/src/tools/examples/calculator/schema.ts b/templates/tool-server-template/src/tools/examples/calculator/schema.ts deleted file mode 100644 index d7daaabe2..000000000 --- a/templates/tool-server-template/src/tools/examples/calculator/schema.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { JSONSchema } from "@llumiverse/common"; - -export interface CalculatorParams { - expression: string; -} - -export const Schema = { - type: "object", - properties: { - expression: { - type: "string", - description: "A mathematical expression to evaluate (e.g., '2 + 2', '10 * 5 - 3', '2^8')" - } - }, - required: ["expression"] -} satisfies JSONSchema; diff --git a/templates/tool-server-template/src/tools/examples/icon.svg.ts b/templates/tool-server-template/src/tools/examples/icon.svg.ts deleted file mode 100644 index 7b19d5989..000000000 --- a/templates/tool-server-template/src/tools/examples/icon.svg.ts +++ /dev/null @@ -1,7 +0,0 @@ -export default ` - - - - - -`; diff --git a/templates/tool-server-template/src/tools/examples/index.ts b/templates/tool-server-template/src/tools/examples/index.ts deleted file mode 100644 index f413f9e45..000000000 --- a/templates/tool-server-template/src/tools/examples/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ToolCollection } from "@vertesia/tools-sdk"; -import { CalculatorTool } from "./calculator/index.js"; -import icon from "./icon.svg.js"; - -export const ExampleTools = new ToolCollection({ - name: "examples", - title: "Example Tools", - description: "A collection of example tools", - icon, - tools: [CalculatorTool] -}); diff --git a/templates/tool-server-template/src/tools/index.ts b/templates/tool-server-template/src/tools/index.ts deleted file mode 100644 index 269d15021..000000000 --- a/templates/tool-server-template/src/tools/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { ExampleTools } from "./examples/index.js"; - -export const tools = [ - ExampleTools -]; diff --git a/templates/tool-server-template/template.config.json b/templates/tool-server-template/template.config.json deleted file mode 100644 index f364aff97..000000000 --- a/templates/tool-server-template/template.config.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "version": "1.0", - "description": "A template for building custom tool servers with tools, skills, and interactions", - "prompts": [ - { - "type": "text", - "name": "PROJECT_NAME", - "message": "Project name", - "initial": "my-tool-server" - }, - { - "type": "text", - "name": "PROJECT_DESCRIPTION", - "message": "Project description", - "initial": "A custom tool server with tools, skills, and interactions" - } - ], - "derived": { - "SERVER_TITLE": { - "from": "PROJECT_NAME", - "transform": "titleCase" - } - }, - "files": [ - "src/config.ts" - ], - "removeAfterInstall": [ - "template.config.json" - ] -} \ No newline at end of file diff --git a/templates/tool-server-template/tsconfig.json b/templates/tool-server-template/tsconfig.json deleted file mode 100644 index cde4abe94..000000000 --- a/templates/tool-server-template/tsconfig.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2024", - "module": "ES2022", - "lib": [ - "ES2024" - ], - "moduleResolution": "bundler", - "resolveJsonModule": true, - "allowJs": true, - "checkJs": false, - "outDir": "./lib", - "rootDir": "./src", - "removeComments": true, - "sourceMap": true, - "declaration": true, - "declarationMap": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "strict": true, - "skipLibCheck": true, - "allowSyntheticDefaultImports": true, - "types": [ - "node" - ] - }, - "include": [ - "src/**/*" - ], - "exclude": [ - "node_modules", - "lib", - "dist", - "src/**/*.tsx" - ] -} \ No newline at end of file diff --git a/templates/tool-server-template/tsconfig.widgets.json b/templates/tool-server-template/tsconfig.widgets.json deleted file mode 100644 index f7ec500bd..000000000 --- a/templates/tool-server-template/tsconfig.widgets.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2024", - "module": "ES2022", - "lib": [ - "ES2024", - "DOM", - "DOM.Iterable" - ], - "jsx": "react-jsx", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "allowJs": true, - "rootDir": "src", - "outDir": "./dist/widgets", - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "strict": true, - "skipLibCheck": true, - "allowSyntheticDefaultImports": true, - "isolatedModules": true - }, - "include": [ - "src/skills/**/*.tsx" - ], - "exclude": [ - "node_modules", - "lib", - "dist" - ] -} \ No newline at end of file diff --git a/templates/tool-server-template/vercel.json b/templates/tool-server-template/vercel.json deleted file mode 100644 index 575b51682..000000000 --- a/templates/tool-server-template/vercel.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "buildCommand": "npm run build", - "outputDirectory": "dist", - "rewrites": [ - { - "source": "/api/(.*)", - "destination": "/api" - } - ], - "headers": [ - { - "source": "/api/(.*)", - "headers": [ - { - "key": "Access-Control-Allow-Origin", - "value": "*" - }, - { - "key": "Access-Control-Allow-Methods", - "value": "GET, POST, PUT, DELETE, OPTIONS" - }, - { - "key": "Access-Control-Allow-Headers", - "value": "Content-Type, Authorization" - } - ] - } - ] -} diff --git a/templates/ui-plugin-template/.env.local.template b/templates/ui-plugin-template/.env.local.template deleted file mode 100644 index 8ed666a60..000000000 --- a/templates/ui-plugin-template/.env.local.template +++ /dev/null @@ -1 +0,0 @@ -VITE_APP_NAME={{APP_ID}} diff --git a/templates/ui-plugin-template/.gitignore b/templates/ui-plugin-template/.gitignore deleted file mode 100644 index 6e30fd631..000000000 --- a/templates/ui-plugin-template/.gitignore +++ /dev/null @@ -1,23 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -lib -*.local - -# Editor directories and files -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? diff --git a/templates/ui-plugin-template/LICENSE b/templates/ui-plugin-template/LICENSE deleted file mode 100644 index 2642274ec..000000000 --- a/templates/ui-plugin-template/LICENSE +++ /dev/null @@ -1,13 +0,0 @@ -Copyright 2026 Vertesia - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - -http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. \ No newline at end of file diff --git a/templates/ui-plugin-template/README.md b/templates/ui-plugin-template/README.md deleted file mode 100644 index 909a3f894..000000000 --- a/templates/ui-plugin-template/README.md +++ /dev/null @@ -1,162 +0,0 @@ -# Vertesia Custom App Sample - -A sample project demonstrating how to build a custom app/plugin for the Vertesia platform using React, TypeScript, and Vite. - -## Overview - -This project serves as a template for building Vertesia plugins that can be integrated into the Vertesia platform. It includes: - -- React 19 with TypeScript for type-safe component development -- Tailwind CSS for styling -- Vite for fast development and optimized builds -- Dual build modes: standalone app and plugin library - -## Project Structure - -```txt -src/ -├── app.tsx # Main app component with router -├── plugin.tsx # Plugin entry point for Vertesia integration -├── routes.tsx # Application route definitions -├── pages.tsx # Page components -└── main.tsx # Dev mode entry point -``` - -## Prerequisites - -- An application manifest [created and installed](/apps/overview) in your Vertesia project - -## Getting Started - -### Installation - -```bash -pnpm install -``` - -Next, set the app Id in the `VITE_APP_NAME` variable in the `.env.local` file. - -### Development - -Run the app in development mode with hot module replacement: - -```bash -pnpm dev -``` - -The app will be available at `https://localhost:5173`. - -### Building - -Build both standalone app and plugin library: - -```bash -pnpm build -``` - -Or build individually: - -```bash -# Build standalone app -pnpm build:app - -# Build plugin library -pnpm build:lib -``` - -The plugin library will be output to the `dist/lib/` directory. - -## Deployment - -Since this is a standard web application, you can deploy it to any static hosting provider (Vercel, Netlify, Cloudflare Pages, AWS S3, etc.). - -### Deploying to Vercel - -Vercel is a practical deployment option with a generous free tier. You can very simply deploy your standalone app using the Vercel CLI. - -#### Setup - -Install the Vercel CLI globally: - -```bash -npm i -g vercel -``` - -#### Deployment Steps - -1. **Login to Vercel**: - - ```bash - vercel login - ``` - -2. **Deploy to preview**: - - ```bash - vercel - ``` - - This will create a preview deployment and provide you with a URL to test your app. - -3. **Deploy to production**: - - ```bash - vercel --prod - ``` - -For more information, visit the [Vercel CLI documentation](https://vercel.com/docs/cli). - -#### Update App Manifest with Deployment URL - -After deploying to Vercel, update your app manifest to point to the deployed URL using the vertesia CLI: - -```bash -vertesia apps update --manifest '{ - "name": "my-app", - "title": "My App", - "description": "A sample app", - "publisher": "your-org", - "private": true, - "status": "beta", - "ui": { - "src": "https://your-app.vercel.app/lib/plugin.js", - "isolation": "shadow" - } -}' -``` - -Replace `appId` by the actual ID and `https://your-app.vercel.app` with your actual Vercel deployment URL. - -## Key Features - -### Dual Build Modes - -- **App Mode**: Builds a standalone application for development and testing -- **Library Mode**: Builds a plugin that can be integrated into the Vertesia platform - -### External Dependencies - -When building as a plugin, React and Vertesia dependencies are externalized to prevent duplication: - -- `react` / `react-dom` -- `@vertesia/common` -- `@vertesia/ui` - -## Tech Stack - -- **React 19** - UI framework -- **TypeScript** - Type safety -- **Vite** - Build tool and dev server -- **Tailwind CSS 4** - Styling -- **@vertesia/ui** - Vertesia UI components -- **@vertesia/plugin-builder** - Plugin build utilities - -## Development Notes - -- The dev server uses HTTPS (via `@vitejs/plugin-basic-ssl`) -- CSS can be inlined in the plugin bundle or kept separate (configured in [vite.config.ts](vite.config.ts)) -- For debugging Vertesia UI sources, set `VERTESIA_UI_PATH` in [vite.config.ts](vite.config.ts) - -## License - -See package.json for license information. diff --git a/templates/ui-plugin-template/eslint.config.js b/templates/ui-plugin-template/eslint.config.js deleted file mode 100644 index d21df2c80..000000000 --- a/templates/ui-plugin-template/eslint.config.js +++ /dev/null @@ -1,29 +0,0 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import tseslint from 'typescript-eslint' -import { defineConfig } from 'eslint/config'; - -export default defineConfig( - { ignores: ['dist'] }, - { - extends: [js.configs.recommended, ...tseslint.configs.recommended], - files: ['**/*.{ts,tsx}'], - languageOptions: { - ecmaVersion: 2020, - globals: globals.browser, - }, - plugins: { - 'react-hooks': reactHooks, - 'react-refresh': reactRefresh, - }, - rules: { - ...reactHooks.configs.recommended.rules, - 'react-refresh/only-export-components': [ - 'warn', - { allowConstantExport: true }, - ], - }, - }, -) diff --git a/templates/ui-plugin-template/index.html b/templates/ui-plugin-template/index.html deleted file mode 100644 index a3c0dc753..000000000 --- a/templates/ui-plugin-template/index.html +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - {{PLUGIN_TITLE}} - - - - -
- - - - \ No newline at end of file diff --git a/templates/ui-plugin-template/package.json b/templates/ui-plugin-template/package.json deleted file mode 100644 index 8cec4c771..000000000 --- a/templates/ui-plugin-template/package.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "name": "ui-plugin-template", - "version": "1.0.0", - "description": "A template for building custom UI plugins for Vertesia", - "type": "module", - "files": [ - "dist" - ], - "author": "Vertesia", - "license": "Apache-2.0", - "scripts": { - "dev": "vite dev", - "build:app": "vite build --mode app", - "build:lib": "vite build --mode lib", - "build": "vite build --mode app && vite build --mode lib", - "lint": "eslint .", - "preview": "vite preview" - }, - "peerDependencies": { - "react": "^19.0.0", - "react-dom": "^19.0.0" - }, - "devDependencies": { - "@eslint/js": "^10.0.1", - "@tailwindcss/forms": "^0.5.10", - "@tailwindcss/vite": "^4.1.18", - "@types/node": "^24.1.0", - "@types/react": "^19.1.6", - "@types/react-dom": "^19.1.5", - "@vertesia/plugin-builder": "workspace:*", - "@vitejs/plugin-basic-ssl": "^2.1.0", - "@vitejs/plugin-react": "^5.1.2", - "eslint": "^10.0.2", - "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.5.2", - "globals": "^17.3.0", - "react": "^19.1.2", - "react-dom": "^19.1.2", - "tailwindcss": "^4.1.18", - "typescript": "^6.0.2", - "typescript-eslint": "^8.58.1", - "vite": "^7.3.0", - "vite-plugin-serve-static": "^1.2.0" - }, - "dependencies": { - "@vertesia/common": "workspace:*", - "@vertesia/ui": "workspace:*" - } -} \ No newline at end of file diff --git a/templates/ui-plugin-template/src/app.tsx b/templates/ui-plugin-template/src/app.tsx deleted file mode 100644 index be6d02a32..000000000 --- a/templates/ui-plugin-template/src/app.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { NestedRouterProvider } from "@vertesia/ui/router"; -import { routes } from "./routes"; - -export function App() { - return ( - - ) -} \ No newline at end of file diff --git a/templates/ui-plugin-template/src/assets.ts b/templates/ui-plugin-template/src/assets.ts deleted file mode 100644 index 01fdc7806..000000000 --- a/templates/ui-plugin-template/src/assets.ts +++ /dev/null @@ -1,26 +0,0 @@ - -let _usePluginAssets = true; - -export function setUsePluginAssets(usePluginAssets: boolean) { - _usePluginAssets = usePluginAssets -} - -/** - * Correctly resolve the URL to an asset so that it works in dev mode but also in prod as a standalone app or plugin. - * Assets must be put inside /public/assets folder so the given `path` will be resolved as /assets/path in the right context - * @param path - */ -export function useAsset(path: string) { - if (path.startsWith('/')) { - path = path.substring(1); - } else if (path.startsWith('./')) { - path = path.substring(2); - } - if (_usePluginAssets) { - // the plugin.js file is in lib/ directory and we need to serve from assets/ directory - path = `../assets/${path}`; - return new URL(path, import.meta.url).href; - } else { - return `/assets/${path}`; - } -} \ No newline at end of file diff --git a/templates/ui-plugin-template/src/env.ts b/templates/ui-plugin-template/src/env.ts deleted file mode 100644 index da1d16498..000000000 --- a/templates/ui-plugin-template/src/env.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Env } from "@vertesia/ui/env"; - -const CONFIG__PLUGIN_TITLE = "Ui Plugin Template"; - -Env.init({ - name: CONFIG__PLUGIN_TITLE, - version: "1.0.0", - isLocalDev: true, - isDocker: true, - type: "development", - endpoints: { - studio: "https://api.us1.vertesia.io", - zeno: "https://api.us1.vertesia.io", - sts: "https://sts.us1.vertesia.io", - } -}); diff --git a/templates/ui-plugin-template/src/index.css b/templates/ui-plugin-template/src/index.css deleted file mode 100644 index 4014d8a3c..000000000 --- a/templates/ui-plugin-template/src/index.css +++ /dev/null @@ -1,21 +0,0 @@ -@import "tailwindcss"; - -/** - * The login form use this for inputs - */ -@plugin '@tailwindcss/forms'; - -/** - * Scan typescript sources - */ -@source "./"; - -/** - * Scan the index.html for tailwind CSS - */ -@source "../index.html"; - -/** - * Scan @vertesia/ui dependency for tailwind classes - */ -@source "../node_modules/@vertesia/ui/src"; \ No newline at end of file diff --git a/templates/ui-plugin-template/src/main.tsx b/templates/ui-plugin-template/src/main.tsx deleted file mode 100644 index fb08c9e88..000000000 --- a/templates/ui-plugin-template/src/main.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { I18nProvider } from '@vertesia/ui/i18n' -import { StandaloneApp, VertesiaShell } from '@vertesia/ui/shell' -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import './index.css' -// initialize dev environment -import { RouterProvider } from '@vertesia/ui/router' -import { App } from './app' -import "./env" -import { setUsePluginAssets } from './assets' - -setUsePluginAssets(false); - -createRoot(document.getElementById('root')!).render( - - - - {/* <---- define VITE_APP_NAME en var in .env.local */} - - - - - , -) diff --git a/templates/ui-plugin-template/src/pages.tsx b/templates/ui-plugin-template/src/pages.tsx deleted file mode 100644 index c22f9f4ed..000000000 --- a/templates/ui-plugin-template/src/pages.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { useNavigate } from "@vertesia/ui/router"; -import { useUserSession } from "@vertesia/ui/session"; -import type { ReactNode } from "react"; - -export function HomePage() { - const { user } = useUserSession(); - return ( -
-

Hello {user?.email}!

- Go to next page -
- ) -} - -export function NextPage() { - return ( -
-

Hello again!

- Go to previous page -
- ) -} - -function NavButton({ href, children }: { href: string, children: ReactNode }) { - const navigate = useNavigate(); - return ( - - ) -} \ No newline at end of file diff --git a/templates/ui-plugin-template/src/plugin.tsx b/templates/ui-plugin-template/src/plugin.tsx deleted file mode 100644 index 923e37c29..000000000 --- a/templates/ui-plugin-template/src/plugin.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { PortalContainerProvider } from "@vertesia/ui/core"; -import { App } from "./app"; - -/** - * Export the plugin component. - */ -export default function TEMPLATE__PluginComponentName({ slot }: { slot: string }) { - // Render the plugin component based on the slot. - // Slot "page" is used in the App Portal - // Slot "content" is used in the Composite App - if (slot === "page" || slot === "content") { - return ( - - - - ); - } else { - console.warn('No component found for slot', slot); - return null; - } -} diff --git a/templates/ui-plugin-template/src/routes.tsx b/templates/ui-plugin-template/src/routes.tsx deleted file mode 100644 index 41cedfef0..000000000 --- a/templates/ui-plugin-template/src/routes.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { HomePage, NextPage } from "./pages"; - -export const routes = [ - { - path: '/', - Component: HomePage, - }, - { - path: '/next', - Component: NextPage, - }, - { - path: '*', - Component: () =>
Not found
, - } - -]; \ No newline at end of file diff --git a/templates/ui-plugin-template/src/vite-env.d.ts b/templates/ui-plugin-template/src/vite-env.d.ts deleted file mode 100644 index 11f02fe2a..000000000 --- a/templates/ui-plugin-template/src/vite-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/templates/ui-plugin-template/template.config.json b/templates/ui-plugin-template/template.config.json deleted file mode 100644 index 9ff0fdaf3..000000000 --- a/templates/ui-plugin-template/template.config.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "version": "1.0", - "description": "A template for building custom UI plugins for Vertesia", - "prompts": [ - { - "type": "text", - "name": "PROJECT_NAME", - "message": "Project name (use kebab case)", - "initial": "my-plugin" - }, - { - "type": "text", - "name": "PROJECT_DESCRIPTION", - "message": "Project description", - "initial": "A custom UI plugin for Vertesia" - }, - { - "type": "select", - "name": "inlineCss", - "message": "CSS isolation strategy", - "initial": 0, - "choices": [ - { - "title": "Shadow DOM", - "description": "Shadow DOM will be used to fully isolate the plugin.", - "value": false - }, - { - "title": "CSS-only isolation", - "description": "Injects Tailwind utilities into host DOM; not fully isolated. Lighter but may generate conflicts.", - "value": true - } - ] - } - ], - "derived": { - "PLUGIN_TITLE": { - "from": "PROJECT_NAME", - "transform": "titleCase" - }, - "PluginComponentName": { - "from": "PROJECT_NAME", - "transform": "pascalCase" - } - }, - "files": [ - "vite.config.ts", - "index.html", - "src/env.ts", - "src/plugin.tsx", - ".env.local.template" - ], - "renameFiles": { - ".env.local.template": ".env.local" - }, - "removeAfterInstall": [ - "template.config.json" - ] -} \ No newline at end of file diff --git a/templates/ui-plugin-template/tsconfig.app.json b/templates/ui-plugin-template/tsconfig.app.json deleted file mode 100644 index ab7277657..000000000 --- a/templates/ui-plugin-template/tsconfig.app.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "target": "ES2024", - "useDefineForClassFields": true, - "lib": [ - "ES2024", - "DOM", - "DOM.Iterable" - ], - "module": "ESNext", - "skipLibCheck": true, - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "moduleDetection": "force", - "noEmit": true, - "jsx": "react-jsx", - "baseUrl": ".", - "paths": { - "@vertesia/common": [ - "./node_modules/@vertesia/common/src/index.ts" - ], - "@vertesia/client": [ - "./node_modules/@vertesia/client/src/index.ts", - ], - "@vertesia/ui/*": [ - "./node_modules/@vertesia/ui/src/*" - ] - }, - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "erasableSyntaxOnly": false, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true - }, - "include": [ - "src" - ], - "references": [ - { - "path": "./node_modules/@vertesia/common/tsconfig.dist.json" - }, - { - "path": "./node_modules/@vertesia/ui/tsconfig.dist.json" - } - ] -} \ No newline at end of file diff --git a/templates/ui-plugin-template/tsconfig.json b/templates/ui-plugin-template/tsconfig.json deleted file mode 100644 index 1ffef600d..000000000 --- a/templates/ui-plugin-template/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "files": [], - "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } - ] -} diff --git a/templates/ui-plugin-template/tsconfig.node.json b/templates/ui-plugin-template/tsconfig.node.json deleted file mode 100644 index 9ba9773b8..000000000 --- a/templates/ui-plugin-template/tsconfig.node.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", - "target": "ES2024", - "lib": [ - "ES2024" - ], - "module": "ESNext", - "skipLibCheck": true, - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "moduleDetection": "force", - "noEmit": true, - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "erasableSyntaxOnly": false, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true - }, - "include": [ - "vite.config.ts" - ] -} \ No newline at end of file diff --git a/templates/ui-plugin-template/vite.config.ts b/templates/ui-plugin-template/vite.config.ts deleted file mode 100644 index 3c36a4642..000000000 --- a/templates/ui-plugin-template/vite.config.ts +++ /dev/null @@ -1,128 +0,0 @@ -import tailwindcss from '@tailwindcss/vite'; -import { vertesiaPluginBuilder } from '@vertesia/plugin-builder'; -import basicSsl from '@vitejs/plugin-basic-ssl'; -import react from '@vitejs/plugin-react'; -import { defineConfig, type ConfigEnv, type UserConfig } from 'vite'; -import serveStatic from "vite-plugin-serve-static"; - - -/** - * List of dependencies that must be bundled in the plugin bundle - */ -const INTERNALS: (string | RegExp)[] = [ -]; - -function isExternal(id: string) { - // If it matches INTERNALS → bundle it - if (INTERNALS.some(pattern => - pattern instanceof RegExp ? pattern.test(id) : id === pattern - )) { - return false; - } - - // Otherwise → treat all bare imports (node_modules deps) as external - return !id.startsWith('.') && !id.startsWith('/') && !id.startsWith('@/') && !id.startsWith('virtual:'); -} - - -/** - * if you want to debug vertesia ui sources define a relative path to the vertesia ui package root - */ -const VERTESIA_UI_PATH = "" - -/** - * Set to true to extract the css utility layer and inject it in the plugin js file. - * If you use shadow dom isolation for the plugin you must set this to false. - */ -const CONFIG__inlineCss = false; - -/** - * Vite configuration to build the plugin as a library or as a standalone application or to run the application in dev mode. - * Use `vite build --mode lib` to build a library (plugin) - * Use `vite build` or `vite build --mode app`to build a standalone application - * Use `vite dev` to run the application in dev mode. - */ -export default defineConfig((env) => { - if (env.mode === 'lib') { - return defineLibConfig(env); - } else { - return defineAppConfig(); - } -}) - -/** - * Vite configuration to build a library (plugin). - * @param env - Vite configuration environment - * @returns - */ -function defineLibConfig({ command }: ConfigEnv): UserConfig { - const isBuildMode = command === 'build'; - if (!isBuildMode) { - throw new Error("Library config is only available in 'build' mode. Please use 'lib' mode for library builds."); - } - return { - plugins: [ - tailwindcss(), - react(), - vertesiaPluginBuilder({ inlineCss: CONFIG__inlineCss }), - ], - build: { - outDir: 'dist/lib', // the plugin will be generated in the `dist/lib` directory - lib: { - entry: './src/plugin.tsx', // Main entry point of your library - formats: ['es'], // Build ESM versions - fileName: "plugin", - }, - minify: true, - sourcemap: true, - rollupOptions: { - external: isExternal, - } - } - } -} - -/** - * Vite configuration to run the application in dev mode - * or to build a standalone application. - * @returns - */ -function defineAppConfig(): UserConfig { - - return { - plugins: [ - tailwindcss(), - react(), - // we need to use https for the firebase authentication to work - basicSsl(), - // serve lib/plugin.js content in dev mode - serveStatic([ - { - pattern: new RegExp("/plugin.(js|css)"), - resolve: (groups: string[]) => `./dist/lib/plugin.${groups[1]}` - }, - ]), - ], - // for authentication with Firebase - server: { - proxy: { - '/__/auth': { - target: 'https://dengenlabs.firebaseapp.com', - changeOrigin: true, - } - } - }, - resolve: { - // For debug support in vertesia ui sources - link to the vertesia/ui location - alias: VERTESIA_UI_PATH ? { - "@vertesia/ui": resolve(`${VERTESIA_UI_PATH}/src`) - } : undefined, - // Deduplicate React to prevent multiple instances - dedupe: ['react', 'react-dom'] - } - } -} - -function resolve(path: string) { - return new URL(path, import.meta.url).pathname -} diff --git a/templates/worker-template/.docker/config.json b/templates/worker-template/.docker/config.json deleted file mode 100644 index 586adf071..000000000 --- a/templates/worker-template/.docker/config.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "credHelpers": { - "us-docker.pkg.dev": "vertesia" - } -} \ No newline at end of file diff --git a/templates/worker-template/.dockerignore b/templates/worker-template/.dockerignore deleted file mode 100644 index e197af264..000000000 --- a/templates/worker-template/.dockerignore +++ /dev/null @@ -1,7 +0,0 @@ -.env -.DS_Store -.git -.gitignore -*.md -lib/ -node_modules/ diff --git a/templates/worker-template/.env.template b/templates/worker-template/.env.template deleted file mode 100644 index 6805df3e3..000000000 --- a/templates/worker-template/.env.template +++ /dev/null @@ -1,16 +0,0 @@ -# NAME: The name of the app. -NAME={{SERVICE_NAME}} -# VERSION: The version of the app. -VERSION=1.0.0 - -# ENVIRONMENT: The name of the environment that the app is running in. -# Please use the following format: desktop-org-[username|appname] -ENVIRONMENT=desktop-{{SERVICE_NAME}} - -# IS_LOCAL_DEV: A boolean value that indicates whether the app is running in a local development. -IS_LOCAL_DEV=true - -# TEMPORAL_ADDRESS: The address of the Temporal server. -TEMPORAL_ADDRESS=localhost:7233 -# TEMPORAL_TASK_QUEUE: The task queue to use for the worker. -TEMPORAL_TASK_QUEUE={{SERVICE_NAME}} diff --git a/templates/worker-template/.gitignore b/templates/worker-template/.gitignore deleted file mode 100644 index b48a9caf1..000000000 --- a/templates/worker-template/.gitignore +++ /dev/null @@ -1,25 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -lib -*.local - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? diff --git a/templates/worker-template/.npmrc b/templates/worker-template/.npmrc deleted file mode 100644 index 28931b3da..000000000 --- a/templates/worker-template/.npmrc +++ /dev/null @@ -1 +0,0 @@ -@dglabs:registry=https://us-central1-npm.pkg.dev/dengenlabs/npm/ diff --git a/templates/worker-template/Dockerfile b/templates/worker-template/Dockerfile deleted file mode 100644 index dbb85701c..000000000 --- a/templates/worker-template/Dockerfile +++ /dev/null @@ -1,28 +0,0 @@ -FROM node:24-slim AS base -ENV PNPM_HOME="/pnpm" -ENV PATH="$PNPM_HOME:$PATH" -RUN corepack enable -COPY . /app -WORKDIR /app - -FROM base AS prod-deps -RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile - -FROM base AS build -RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile -RUN pnpm run build - -FROM base -RUN apt-get update -y && \ - apt-get install -y openssl ca-certificates - -COPY --from=prod-deps /app/node_modules /app/node_modules -COPY --from=build /app/lib /app/lib -COPY --from=build /app/bin /app/bin - -COPY --from=datadog/serverless-init:1 /datadog-init /app/datadog-init - -ENV SERVICE_NAME={{SERVICE_NAME}} - -ENTRYPOINT ["/app/datadog-init"] -CMD [ "pnpm", "start" ] diff --git a/templates/worker-template/README.md b/templates/worker-template/README.md deleted file mode 100644 index ca1ac1fe6..000000000 --- a/templates/worker-template/README.md +++ /dev/null @@ -1,218 +0,0 @@ -# {{SERVICE_NAME}} - -{{PROJECT_DESCRIPTION}} - -## Overview - -This is a Vertesia custom worker built on Temporal workflows. It provides a starting point for building workflow-based automation that integrates with the Vertesia platform. - -## Architecture - -### Core Components - -- **Workflows** (`src/workflows.ts`): Workflow definitions that orchestrate activities - - `exampleWorkflow`: Processes content objects with support for dry-run mode - - `inspectObjectsWorkflow`: Retrieves metadata from content objects without modifications -- **Activities** (`src/activities.ts`): Core processing activities that perform actual work - - `processObjectActivity`: Retrieves and processes a content object - - `getObjectMetadataActivity`: Fetches object metadata for inspection -- **Types**: Activity parameters and results are defined with proper TypeScript interfaces -- **Main Entry** (`src/main.ts`): Worker runner that loads workflow bundle and activities -- **Debug Replayer** (`src/debug-replayer.ts`): Temporal debug tooling for workflow replay and testing -- **Test Utils** (`src/test/utils.ts`): Shared testing utilities and helpers - -### Processing Flow - -1. Workflow receives a payload with `objectIds` to process -2. Each object is processed through activities -3. Activities use the Vertesia client to interact with the platform -4. Results are aggregated and returned - -## Development - -### Prerequisites - -- Node.js 22+ -- pnpm package manager -- Vertesia CLI (`@vertesia/cli`) - -### Setup - -Connect to your Vertesia project (this sets up npm registry authentication): - -```bash -vertesia worker connect -``` - -Install dependencies: - -```bash -pnpm install -``` - -### Build - -```bash -pnpm run build -``` - -### Testing - -Uses Vitest with Temporal testing utilities. Test files use `.test.ts` extension. - -```bash -pnpm test -``` - -Run tests in watch mode: - -```bash -pnpm test -- --watch -``` - -## Configuration - -### Package Configuration - -Worker configuration is defined in `package.json` under the `vertesia` section: - -```json -{ - "vertesia": { - "image": { - "repository": "us-docker.pkg.dev/dengenlabs/us.gcr.io", - "organization": "{{WORKER_ORG}}", - "name": "{{WORKER_NAME}}" - } - } -} -``` - -## Run with a Local Temporal Server - -Running with a local Temporal server is useful for integration testing before deployment. - -### 1. Install the Temporal CLI - -```bash -# macOS -brew install temporal - -# Other platforms: https://docs.temporal.io/cli -``` - -### 2. Start the Dev Server - -```bash -temporal server start-dev -``` - -### 3. Start the Worker - -In another terminal: - -```bash -pnpm run start -``` - -### 4. Start a Workflow - -Using the Temporal CLI: - -```bash -temporal workflow start --name exampleWorkflow -t {{SERVICE_NAME}} --input-file input.json -``` - -Where `input.json` contains the workflow parameters: - -```json -{ - "objectIds": ["object-id-1", "object-id-2"], - "event": "workflow_execution_request", - "auth_token": "your-auth-token", - "account_id": "your-account-id", - "project_id": "your-project-id", - "config": { - "store_url": "https://zeno-server.api.vertesia.io", - "studio_url": "https://studio-server.api.vertesia.io" - }, - "vars": { - "dryRun": true - } -} -``` - -## Deployment - -### Build Docker Image - -```bash -vertesia worker build -``` - -### Create a Release - -```bash -vertesia worker release X.Y.Z -``` - -### Publish and Deploy - -```bash -vertesia worker publish X.Y.Z -``` - -**Note:** Deployments are per environment based on your current CLI profile. - -## Running in Production - -Once deployed, the workflow can be started using the API, CLI, SDK, or Workflow rules. - -### Using the SDK - -```javascript -const run = await client.workflows.execute("exampleWorkflow", { - task_queue: "workers/{{WORKER_ORG}}/{{WORKER_NAME}}", - objectIds: ["object-id-1"], - vars: { - dryRun: false, - }, -}); -``` - -### Using the CLI - -```bash -vertesia workflows execute exampleWorkflow \ - -o \ - --queue "workers/{{WORKER_ORG}}/{{WORKER_NAME}}" \ - -f workflow_vars.json -``` - -### Using curl - -```bash -curl --location 'https://api.vertesia.io/api/v1/workflows/execute/exampleWorkflow' \ - --header 'Authorization: Bearer ' \ - --header 'Content-Type: application/json' \ - --data '{ - "vars": { "dryRun": false }, - "task_queue": "workers/{{WORKER_ORG}}/{{WORKER_NAME}}", - "objectIds": ["object-id-1"] - }' -``` - -## Workflow Variables - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `dryRun` | boolean | `false` | If true, simulates processing without making changes | - -## Dependencies - -Built with: - -- **Temporal**: Workflow orchestration -- **Vertesia SDK**: Platform integration (`@vertesia/client`, `@vertesia/workflow`) -- **TypeScript**: Type-safe development -- **Vitest**: Testing framework diff --git a/templates/worker-template/bin/bundle-workflows.mjs b/templates/worker-template/bin/bundle-workflows.mjs deleted file mode 100755 index 02ef3afa4..000000000 --- a/templates/worker-template/bin/bundle-workflows.mjs +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env node - -import { bundleWorkflowCode } from '@temporalio/worker'; -import { writeFile } from 'node:fs/promises'; -import path from 'node:path'; - -async function bundle(wsPath, bundlePath) { - const { code } = await bundleWorkflowCode({ - workflowsPath: path.resolve(wsPath), - webpackConfigHook: (config) => { - // Fix for Temporal's VM sandbox environment: - // 1. publicPath: '' - disable auto-detection (no document.currentScript) - // 2. chunkLoading: 'import' - use import() instead of JSONP (no 'self' global) - // 3. globalObject: 'globalThis' - use globalThis instead of self/window - config.output = { - ...config.output, - publicPath: '', - chunkLoading: 'import', - globalObject: 'globalThis', - }; - return config; - }, - }); - const codePath = path.resolve(bundlePath); - await writeFile(codePath, code); - console.log(`Bundle written to ${codePath}`); -} - -const wsPath = process.argv[2]; -const bundlePath = process.argv[3]; -if (!wsPath || !bundlePath) { - console.error('Usage: build-workflows '); - process.exit(1); -} - -bundle(wsPath, bundlePath).catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/templates/worker-template/eslint.config.js b/templates/worker-template/eslint.config.js deleted file mode 100644 index d18eb0da2..000000000 --- a/templates/worker-template/eslint.config.js +++ /dev/null @@ -1,34 +0,0 @@ -import eslint from "@eslint/js"; -import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"; -import { defineConfig } from "eslint/config"; -import tseslint from "typescript-eslint"; - -export default defineConfig( - eslint.configs.recommended, - ...tseslint.configs.recommendedTypeChecked, - eslintPluginPrettierRecommended, - { - languageOptions: { - parserOptions: { - project: "./tsconfig.eslint.json", - tsconfigRootDir: import.meta.dirname, - }, - }, - }, - { - rules: { - "@typescript-eslint/no-floating-promises": "error", - "@typescript-eslint/no-unused-vars": [ - "error", - { - argsIgnorePattern: '^_', - varsIgnorePattern: '^_', - }, - ], - '@typescript-eslint/no-unused-expressions': ['error', { - allowShortCircuit: true, - allowTernary: true, - }], - }, - }, -); diff --git a/templates/worker-template/package.json b/templates/worker-template/package.json deleted file mode 100644 index f44380de9..000000000 --- a/templates/worker-template/package.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "name": "@{{WORKER_ORG}}/{{WORKER_NAME}}", - "version": "1.1.0", - "description": "{{PROJECT_DESCRIPTION}}", - "type": "module", - "main": "lib/main.js", - "types": "lib/main.d.ts", - "scripts": { - "clean": "rimraf ./lib tsconfig.tsbuildinfo", - "lint": "pnpm exec eslint 'src/**/*' --fix", - "format": "pnpm exec prettier --write \"**/*.{ts,js,json,md}\"", - "prebuild": "pnpm run lint", - "build": "pnpm run clean && tsc --build && node ./bin/bundle-workflows.mjs lib/workflows.js lib/workflows-bundle.js", - "typecheck:test": "tsc --project tsconfig.test.json", - "pretest": "pnpm run typecheck:test", - "start": "node lib/main.js", - "connect": "vertesia worker connect", - "test": "vitest" - }, - "keywords": [ - "workflow", - "temporal", - "llm", - "vertesia", - "worker" - ], - "author": "Vertesia", - "license": "Apache-2.0", - "dependencies": { - "@dglabs/worker": "^0.11.0", - "@temporalio/activity": "^1.13.0", - "@temporalio/client": "^1.13.0", - "@temporalio/worker": "^1.13.0", - "@temporalio/workflow": "^1.13.0", - "@vertesia/client": "workspace:*", - "@vertesia/common": "workspace:*", - "@vertesia/workflow": "workspace:*" - }, - "devDependencies": { - "@eslint/compat": "^2.0.2", - "@eslint/js": "^10.0.1", - "eslint": "^10.0.2", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-prettier": "^5.5.5", - "@types/node": "^24.1.0", - "prettier": "3.5.3", - "rimraf": "^6.0.1", - "@temporalio/proto": "^1.13.0", - "@temporalio/testing": "^1.13.0", - "typescript": "^5.9.2", - "typescript-eslint": "^8.56.1", - "vitest": "^4.0.16", - "vitest-fetch-mock": "^0.4.5" - }, - "vertesia": { - "image": { - "repository": "us-docker.pkg.dev/dengenlabs/us.gcr.io", - "organization": "{{WORKER_ORG}}", - "name": "{{WORKER_NAME}}" - } - } -} diff --git a/templates/worker-template/src/activities.test.ts b/templates/worker-template/src/activities.test.ts deleted file mode 100644 index deecb066b..000000000 --- a/templates/worker-template/src/activities.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { MockActivityEnvironment } from '@temporalio/testing'; -import { ContentObject } from '@vertesia/common'; -import { getVertesiaClient } from '@vertesia/workflow'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import * as activities from './activities.js'; -import { ActivityExecutionPayload, ProcessObjectParams, ProcessObjectResult } from './activities.js'; -import { getMockActivityPayload } from './test/utils.js'; - -// Mock the @vertesia/workflow module -vi.mock('@vertesia/workflow', async () => { - const actual = await vi.importActual('@vertesia/workflow'); - return { - ...actual, - getVertesiaClient: vi.fn(), - }; -}); - -beforeEach(() => { - vi.clearAllMocks(); -}); - -afterEach(() => { - vi.clearAllMocks(); -}); - -describe('processObjectActivity', () => { - it('successfully processes an object', async () => { - const mockObjectId = 'test-object-id'; - const mockObjectName = 'Test Document'; - - // Mock the Vertesia client - vi.mocked(getVertesiaClient).mockReturnValue({ - objects: { - retrieve: vi.fn().mockResolvedValue({ - id: mockObjectId, - name: mockObjectName, - } satisfies Partial), - }, - } as never); - - const env = new MockActivityEnvironment(); - const payload: ActivityExecutionPayload = getMockActivityPayload({ - objectId: mockObjectId, - }); - - const result: ProcessObjectResult = await env.run(activities.processObjectActivity, payload); - - expect(result).toEqual({ - objectId: mockObjectId, - name: mockObjectName, - success: true, - }); - }); - - it('handles dry run mode', async () => { - const mockObjectId = 'test-object-id'; - const mockObjectName = 'Test Document'; - - // Mock the Vertesia client - vi.mocked(getVertesiaClient).mockReturnValue({ - objects: { - retrieve: vi.fn().mockResolvedValue({ - id: mockObjectId, - name: mockObjectName, - } satisfies Partial), - }, - } as never); - - const env = new MockActivityEnvironment(); - const payload: ActivityExecutionPayload = { - ...getMockActivityPayload({ objectId: mockObjectId }), - vars: { dryRun: true }, - }; - - const result: ProcessObjectResult = await env.run(activities.processObjectActivity, payload); - - expect(result).toEqual({ - objectId: mockObjectId, - name: mockObjectName, - success: true, - message: 'Dry run - no changes made', - }); - }); -}); - -describe('getObjectMetadataActivity', () => { - it('successfully retrieves object metadata', async () => { - const mockObjectId = 'test-object-id'; - const mockObject = { - id: mockObjectId, - name: 'Test Document', - type: { id: '123', name: 'Document' }, - properties: { author: 'Test Author' }, - } satisfies Partial; - - // Mock the Vertesia client - vi.mocked(getVertesiaClient).mockReturnValue({ - objects: { - retrieve: vi.fn().mockResolvedValue(mockObject), - }, - } as never); - - const env = new MockActivityEnvironment(); - const payload: ActivityExecutionPayload = getMockActivityPayload({ - objectId: mockObjectId, - }); - - const result = await env.run(activities.getObjectMetadataActivity, payload); - - expect(result).toEqual({ - objectId: mockObjectId, - name: 'Test Document', - type: 'Document', - properties: { author: 'Test Author' }, - }); - }); - - it('handles objects without type or properties', async () => { - const mockObjectId = 'test-object-id'; - const mockObject = { - id: mockObjectId, - name: 'Simple Document', - } satisfies Partial; - - // Mock the Vertesia client - vi.mocked(getVertesiaClient).mockReturnValue({ - objects: { - retrieve: vi.fn().mockResolvedValue(mockObject), - }, - } as never); - - const env = new MockActivityEnvironment(); - const payload: ActivityExecutionPayload = getMockActivityPayload({ - objectId: mockObjectId, - }); - - const result = await env.run(activities.getObjectMetadataActivity, payload); - - expect(result).toEqual({ - objectId: mockObjectId, - name: 'Simple Document', - type: undefined, - properties: {}, - }); - }); -}); diff --git a/templates/worker-template/src/activities.ts b/templates/worker-template/src/activities.ts deleted file mode 100644 index e1516bf7a..000000000 --- a/templates/worker-template/src/activities.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { log } from '@temporalio/activity'; -import { WorkflowExecutionPayload } from '@vertesia/common'; -import { getVertesiaClient } from '@vertesia/workflow'; - -/** - * Extended payload interface for activities that need additional parameters - * beyond the standard workflow execution payload. - */ -export interface ActivityExecutionPayload extends WorkflowExecutionPayload { - params: T; -} - -/** - * Parameters for the example workflow. - * Customize these based on your workflow requirements. - */ -export interface ExampleWorkflowParams { - dryRun?: boolean; -} - -/** - * Parameters for processing a single content object. - */ -export interface ProcessObjectParams { - objectId: string; -} - -/** - * Result returned from processing an object. - */ -export interface ProcessObjectResult { - objectId: string; - name: string; - success: boolean; - message?: string; -} - -/** - * Metadata returned from getObjectMetadataActivity. - */ -export interface ObjectMetadata { - objectId: string; - name: string; - type?: string; - properties: Record; -} - -/** - * Example activity that retrieves and processes a content object from Vertesia. - * - * This demonstrates the pattern for: - * - Getting the Vertesia client from the workflow payload - * - Making API calls to the Vertesia platform - * - Supporting dry-run mode for testing - * - Proper error handling and logging - * - * @param payload - The activity execution payload containing auth and params - * @returns The result of processing the object - */ -export async function processObjectActivity( - payload: ActivityExecutionPayload -): Promise { - const { params, vars = {} } = payload; - const { objectId } = params; - - log.info(`Processing object: ${objectId}`); - - // Get the Vertesia client initialized with auth from the payload - const vertesia = getVertesiaClient(payload); - - // Retrieve the content object - const object = await vertesia.objects.retrieve(objectId); - - if (vars.dryRun) { - log.info(`Dry run: would process object "${object.name}"`); - return { - objectId, - name: object.name, - success: true, - message: 'Dry run - no changes made', - }; - } - - // Add your processing logic here - // For example: analyze content, update metadata, trigger other workflows, etc. - - log.info(`Successfully processed object: ${object.name}`); - - return { - objectId, - name: object.name, - success: true, - }; -} - -/** - * Example activity that fetches and returns object metadata. - * - * This is a simpler activity that just retrieves information - * without making changes - useful for validation or inspection workflows. - * - * @param payload - The activity execution payload - * @returns Object metadata - */ -export async function getObjectMetadataActivity( - payload: ActivityExecutionPayload -): Promise { - const { params } = payload; - const { objectId } = params; - - log.info(`Fetching metadata for object: ${objectId}`); - - const vertesia = getVertesiaClient(payload); - const object = await vertesia.objects.retrieve(objectId); - - return { - objectId: object.id, - name: object.name, - type: object.type?.name, - properties: (object.properties as Record) || {}, - }; -} diff --git a/templates/worker-template/src/debug-replayer.ts b/templates/worker-template/src/debug-replayer.ts deleted file mode 100644 index 6bcefbe57..000000000 --- a/templates/worker-template/src/debug-replayer.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Debug replayer for workflow testing. - * - * This tool allows you to replay workflows locally for debugging purposes. - * See https://docs.temporal.io/develop/typescript/debugging for more information. - */ -import { resolveScriptFile } from '@dglabs/worker'; -import { startDebugReplayer } from '@temporalio/worker'; - -await resolveScriptFile('./workflows', import.meta.url).then((p: string) => { - console.log('Debugging using workflows path', p); - startDebugReplayer({ - workflowsPath: p, - }); -}); diff --git a/templates/worker-template/src/main.ts b/templates/worker-template/src/main.ts deleted file mode 100644 index f4bb11c74..000000000 --- a/templates/worker-template/src/main.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Worker entry point. - * - * This file initializes and runs the Temporal worker that executes - * your workflows and activities on the Vertesia platform. - */ -import { resolveScriptFile, run } from '@dglabs/worker'; -import { readFileSync } from 'fs'; -import { fileURLToPath } from 'url'; -import { dirname, resolve } from 'path'; - -interface VertesiaConfig { - name: string; - vertesia?: { - image?: { - organization?: string; - name?: string; - }; - }; -} - -const pkg = readPackageJson(); - -// Construct the worker domain from package.json configuration -// Format: workers/{organization}/{worker-name} -let domain = `workers/${pkg.name}`; - -if (pkg.vertesia?.image?.organization) { - if (pkg.vertesia.image.name) { - domain = `workers/${pkg.vertesia.image.organization}/${pkg.vertesia.image.name}`; - } -} - -// Load the bundled workflow code -const workflowBundle = await resolveScriptFile('./workflows-bundle.js', import.meta.url); - -// Import activities module -const activities = await import('./activities.js'); - -// Start the worker -await run({ - workflowBundle, - activities, - domain, - local: process.env.IS_LOCAL_DEV === 'true', -}) - .catch((err: unknown) => { - console.error(err); - }) - .finally(() => { - process.exit(0); - }); - -function readPackageJson(): VertesiaConfig { - const scriptPath = fileURLToPath(import.meta.url); - const pkgFile = resolve(dirname(scriptPath), '../package.json'); - const content = readFileSync(pkgFile, 'utf8'); - return JSON.parse(content) as VertesiaConfig; -} diff --git a/templates/worker-template/src/test/utils.ts b/templates/worker-template/src/test/utils.ts deleted file mode 100644 index 258de4cb2..000000000 --- a/templates/worker-template/src/test/utils.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { ContentEventName, WorkflowExecutionPayload } from '@vertesia/common'; -import { ActivityExecutionPayload, ExampleWorkflowParams } from '../activities.js'; - -/** - * Creates a mock activity payload for testing. - * - * @param params - The activity-specific parameters - * @returns A complete ActivityExecutionPayload for use in tests - */ -export const getMockActivityPayload = (params: T): ActivityExecutionPayload => { - return { - event: ContentEventName.workflow_execution_request, - objectIds: ['test-object-id'], - auth_token: 'mock-auth-token', - account_id: 'mock-account-id', - project_id: 'mock-project-id', - config: { - studio_url: 'http://mock-studio', - store_url: 'http://mock-store', - }, - vars: {}, - params: params, - }; -}; - -/** - * Creates a mock workflow payload for testing. - * - * @param vars - The workflow variables - * @param objectIds - Optional array of object IDs (defaults to single test ID) - * @returns A complete WorkflowExecutionPayload for use in tests - */ -export const getMockWorkflowPayload = ( - vars: T, - objectIds: string[] = ['test-object-id'] -): WorkflowExecutionPayload => { - return { - event: ContentEventName.workflow_execution_request, - objectIds, - auth_token: 'mock-auth-token', - account_id: 'mock-account-id', - project_id: 'mock-project-id', - config: { - studio_url: 'http://mock-studio', - store_url: 'http://mock-store', - }, - vars: vars, - }; -}; diff --git a/templates/worker-template/src/vitest.d.ts b/templates/worker-template/src/vitest.d.ts deleted file mode 100644 index dd2e65487..000000000 --- a/templates/worker-template/src/vitest.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { vi as viTest } from 'vitest'; - -declare global { - const vi: typeof viTest; -} - -export {}; diff --git a/templates/worker-template/src/workflows.test.ts b/templates/worker-template/src/workflows.test.ts deleted file mode 100644 index 2627e4af8..000000000 --- a/templates/worker-template/src/workflows.test.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { TestWorkflowEnvironment } from '@temporalio/testing'; -import { Worker } from '@temporalio/worker'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import type { - ActivityExecutionPayload, - ObjectMetadata, - ProcessObjectParams, - ProcessObjectResult, -} from './activities.js'; -import { getMockWorkflowPayload } from './test/utils.js'; -import { exampleWorkflow, inspectObjectsWorkflow, type ExampleWorkflowResult } from './workflows.js'; - -describe('Workflows', () => { - let testEnv: TestWorkflowEnvironment; - - beforeAll(async () => { - testEnv = await TestWorkflowEnvironment.createLocal(); - }); - - afterAll(async () => { - await testEnv?.teardown(); - }); - - describe('exampleWorkflow', () => { - it('successfully processes multiple objects', async () => { - const { client, nativeConnection } = testEnv; - const taskQueue = 'test-example-workflow'; - - // Mock activity implementations - const mockResults: Record = { - 'object-1': { objectId: 'object-1', name: 'Document 1', success: true }, - 'object-2': { objectId: 'object-2', name: 'Document 2', success: true }, - }; - - const activities = { - processObjectActivity: ( - payload: ActivityExecutionPayload - ): Promise => Promise.resolve(mockResults[payload.params.objectId]), - getObjectMetadataActivity: ( - _payload: ActivityExecutionPayload - ): Promise => Promise.resolve({ objectId: '', name: '', properties: {} }), - }; - - const worker = await Worker.create({ - connection: nativeConnection, - taskQueue, - workflowsPath: new URL('./workflows.ts', import.meta.url).pathname, - activities, - }); - - const payload = getMockWorkflowPayload({}, ['object-1', 'object-2']); - - const result: ExampleWorkflowResult = await worker.runUntil( - client.workflow.execute(exampleWorkflow, { - args: [payload], - workflowId: 'test-example-workflow', - taskQueue, - }) - ); - - expect(result.processedObjects).toBe(2); - expect(result.results).toHaveLength(2); - expect(result.results[0]).toEqual({ - objectId: 'object-1', - name: 'Document 1', - success: true, - }); - expect(result.results[1]).toEqual({ - objectId: 'object-2', - name: 'Document 2', - success: true, - }); - }); - - it('handles dry run mode', async () => { - const { client, nativeConnection } = testEnv; - const taskQueue = 'test-example-workflow-dry-run'; - - const activities = { - processObjectActivity: ( - _payload: ActivityExecutionPayload - ): Promise => - Promise.resolve({ - objectId: 'object-1', - name: 'Document 1', - success: true, - message: 'Dry run - no changes made', - }), - getObjectMetadataActivity: ( - _payload: ActivityExecutionPayload - ): Promise => Promise.resolve({ objectId: '', name: '', properties: {} }), - }; - - const worker = await Worker.create({ - connection: nativeConnection, - taskQueue, - workflowsPath: new URL('./workflows.ts', import.meta.url).pathname, - activities, - }); - - const payload = getMockWorkflowPayload({ dryRun: true }, ['object-1']); - - const result: ExampleWorkflowResult = await worker.runUntil( - client.workflow.execute(exampleWorkflow, { - args: [payload], - workflowId: 'test-example-workflow-dry-run', - taskQueue, - }) - ); - - expect(result.processedObjects).toBe(1); - expect(result.results[0]).toEqual({ - objectId: 'object-1', - name: 'Document 1', - success: true, - message: 'Dry run - no changes made', - }); - }); - - it('handles empty objectIds array', async () => { - const { client, nativeConnection } = testEnv; - const taskQueue = 'test-example-workflow-empty'; - - const activities = { - processObjectActivity: ( - _payload: ActivityExecutionPayload - ): Promise => Promise.resolve({ objectId: '', name: '', success: true }), - getObjectMetadataActivity: ( - _payload: ActivityExecutionPayload - ): Promise => Promise.resolve({ objectId: '', name: '', properties: {} }), - }; - - const worker = await Worker.create({ - connection: nativeConnection, - taskQueue, - workflowsPath: new URL('./workflows.ts', import.meta.url).pathname, - activities, - }); - - const payload = getMockWorkflowPayload({}, []); - - const result: ExampleWorkflowResult = await worker.runUntil( - client.workflow.execute(exampleWorkflow, { - args: [payload], - workflowId: 'test-example-workflow-empty', - taskQueue, - }) - ); - - expect(result.processedObjects).toBe(0); - expect(result.results).toHaveLength(0); - }); - }); - - describe('inspectObjectsWorkflow', () => { - it('successfully retrieves metadata for multiple objects', async () => { - const { client, nativeConnection } = testEnv; - const taskQueue = 'test-inspect-workflow'; - - const mockMetadata: Record = { - 'object-1': { - objectId: 'object-1', - name: 'Document 1', - type: 'Report', - properties: { author: 'Alice' }, - }, - 'object-2': { - objectId: 'object-2', - name: 'Document 2', - type: 'Article', - properties: { author: 'Bob' }, - }, - }; - - const activities = { - processObjectActivity: ( - _payload: ActivityExecutionPayload - ): Promise => Promise.resolve({ objectId: '', name: '', success: true }), - getObjectMetadataActivity: ( - payload: ActivityExecutionPayload - ): Promise => Promise.resolve(mockMetadata[payload.params.objectId]), - }; - - const worker = await Worker.create({ - connection: nativeConnection, - taskQueue, - workflowsPath: new URL('./workflows.ts', import.meta.url).pathname, - activities, - }); - - const payload = getMockWorkflowPayload({}, ['object-1', 'object-2']); - - const result = await worker.runUntil( - client.workflow.execute(inspectObjectsWorkflow, { - args: [payload], - workflowId: 'test-inspect-workflow', - taskQueue, - }) - ); - - expect(result.objects).toHaveLength(2); - expect(result.objects[0]).toEqual({ - objectId: 'object-1', - name: 'Document 1', - type: 'Report', - properties: { author: 'Alice' }, - }); - expect(result.objects[1]).toEqual({ - objectId: 'object-2', - name: 'Document 2', - type: 'Article', - properties: { author: 'Bob' }, - }); - }); - }); -}); diff --git a/templates/worker-template/src/workflows.ts b/templates/worker-template/src/workflows.ts deleted file mode 100644 index 600570da2..000000000 --- a/templates/worker-template/src/workflows.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { log, proxyActivities } from '@temporalio/workflow'; -import { WorkflowExecutionPayload } from '@vertesia/common'; -import * as activities from './activities.js'; -import { ExampleWorkflowParams, ProcessObjectResult } from './activities.js'; - -/** - * Proxy activities with retry configuration. - * - * Temporal activities are executed outside the workflow sandbox and can perform - * I/O operations like API calls. The proxy configuration defines: - * - startToCloseTimeout: Maximum time for a single activity attempt - * - retry: Automatic retry behavior for transient failures - */ -const { processObjectActivity, getObjectMetadataActivity } = proxyActivities({ - startToCloseTimeout: '5 minute', - retry: { - initialInterval: '5s', - backoffCoefficient: 2, - maximumAttempts: 5, - maximumInterval: 30 * 1000, // ms - nonRetryableErrorTypes: ['ActivityParamInvalid', 'ActivityParamNotFound'], - }, -}); - -/** - * Result returned from the example workflow. - */ -export interface ExampleWorkflowResult { - processedObjects: number; - results: ProcessObjectResult[]; -} - -/** - * Example workflow that processes content objects from Vertesia. - * - * This workflow demonstrates: - * - Receiving the standard WorkflowExecutionPayload with objectIds - * - Processing multiple objects through activities - * - Aggregating results - * - Using workflow variables (vars) for configuration - * - * The workflow is triggered with: - * - objectIds: Array of content object IDs to process - * - vars: Optional configuration like { dryRun: true } - * - * @param payload - The workflow execution payload from Vertesia - * @returns Summary of processed objects - */ -export async function exampleWorkflow( - payload: WorkflowExecutionPayload -): Promise { - const { objectIds = [] } = payload; - - log.info(`Starting example workflow with ${objectIds.length} objects`); - - const results: ProcessObjectResult[] = []; - - // Process each object sequentially - // For parallel processing, you can use Promise.all with child workflows - for (const objectId of objectIds) { - const result = await processObjectActivity({ - ...payload, - params: { objectId }, - }); - results.push(result); - } - - const successCount = results.filter((r) => r.success).length; - log.info(`Workflow completed: ${successCount}/${objectIds.length} objects processed successfully`); - - return { - processedObjects: results.length, - results, - }; -} - -/** - * Example workflow that inspects objects without modifying them. - * - * This is useful for validation, reporting, or audit workflows - * that need to gather information about content objects. - * - * @param payload - The workflow execution payload from Vertesia - * @returns Metadata for all requested objects - */ -export async function inspectObjectsWorkflow( - payload: WorkflowExecutionPayload -): Promise<{ objects: Array<{ objectId: string; name: string; type?: string; properties: Record }> }> { - const { objectIds = [] } = payload; - - log.info(`Inspecting ${objectIds.length} objects`); - - const objects = []; - - for (const objectId of objectIds) { - const metadata = await getObjectMetadataActivity({ - ...payload, - params: { objectId }, - }); - objects.push(metadata); - } - - return { objects }; -} diff --git a/templates/worker-template/template.config.json b/templates/worker-template/template.config.json deleted file mode 100644 index ca8fd377a..000000000 --- a/templates/worker-template/template.config.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "version": "1.0", - "description": "A template for building Temporal workflow workers for Vertesia", - "packageManager": "pnpm", - "prompts": [ - { - "type": "text", - "name": "WORKER_ORG", - "message": "Organization name (e.g. mycompany)", - "initial": "my-org", - "validate": "^[a-zA-Z_][a-zA-Z_0-9-]*$" - }, - { - "type": "text", - "name": "WORKER_NAME", - "message": "Worker name (e.g. my-worker)", - "initial": "my-worker", - "validate": "^[a-zA-Z_][a-zA-Z_0-9-]*$" - }, - { - "type": "text", - "name": "PROJECT_DESCRIPTION", - "message": "Project description", - "initial": "A Temporal workflow worker for Vertesia" - } - ], - "derived": { - "SERVICE_NAME": { - "from": ["WORKER_ORG", "WORKER_NAME"], - "transform": "concat", - "separator": "_" - } - }, - "files": [ - "package.json", - ".env.template", - "Dockerfile", - "README.md" - ], - "renameFiles": { - ".env.template": ".env" - }, - "preInstall": { - "commands": [ - { - "name": "Connect to Vertesia", - "command": "vertesia worker connect", - "optional": false, - "prompt": "You must connect to a Vertesia project to authenticate with the private npm registry." - } - ], - "cliPackage": "@vertesia/cli" - }, - "removeAfterInstall": [ - "template.config.json" - ] -} diff --git a/templates/worker-template/tsconfig.eslint.json b/templates/worker-template/tsconfig.eslint.json deleted file mode 100644 index 1f165e005..000000000 --- a/templates/worker-template/tsconfig.eslint.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "./tsconfig.json", - "exclude": ["node_modules", "lib"] -} \ No newline at end of file diff --git a/templates/worker-template/tsconfig.json b/templates/worker-template/tsconfig.json deleted file mode 100644 index 85db906b3..000000000 --- a/templates/worker-template/tsconfig.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "compilerOptions": { - "composite": true, - "rootDir": "./src", - "sourceMap": true, - "outDir": "./lib", - "forceConsistentCasingInFileNames": true, - "experimentalDecorators": true /* Enable experimental support for TC39 stage 2 draft decorators. */, - "allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */, - "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */, - "target": "ES2024", - "useDefineForClassFields": true, - "lib": ["ES2024", "DOM"], - "module": "NodeNext", - "moduleResolution": "NodeNext", - "declaration": true, - "declarationMap": true, - "skipLibCheck": true, - /* Bundler mode */ - //"allowImportingTsExtensions": true, - "resolveJsonModule": true, - "isolatedModules": true, - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true - }, - "exclude": [ - "node_modules", - "lib", - "**/test", - "vitest.config.ts", - "vitest.setup.ts", - "**/*.test.ts", - "tools" - ], - "references": [ - { "path": "../../../packages/worker/tsconfig.json" }, - { "path": "../../packages/client/tsconfig.json" }, - { "path": "../../packages/common/tsconfig.json" }, - { "path": "../../packages/workflow/tsconfig.json" } - ] -} \ No newline at end of file diff --git a/templates/worker-template/tsconfig.test.json b/templates/worker-template/tsconfig.test.json deleted file mode 100644 index 167e265a2..000000000 --- a/templates/worker-template/tsconfig.test.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "composite": false, - "noEmit": true, - "rootDir": "." - }, - "include": [ - "src/**/*.test.ts", - "src/test/**/*.ts", - "vitest.config.ts", - "vitest.setup.ts" - ], - "exclude": ["node_modules", "lib"] -} diff --git a/templates/worker-template/vitest.config.ts b/templates/worker-template/vitest.config.ts deleted file mode 100644 index 304db752f..000000000 --- a/templates/worker-template/vitest.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - environment: "node", - globals: true, - setupFiles: ["./vitest.setup.ts"], - typecheck: { - enabled: true, - tsconfig: "./tsconfig.test.json", - }, - }, -}); diff --git a/templates/worker-template/vitest.setup.ts b/templates/worker-template/vitest.setup.ts deleted file mode 100644 index dd7f63669..000000000 --- a/templates/worker-template/vitest.setup.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { vi, type VitestUtils } from "vitest"; -import createFetchMock, { type FetchMock } from "vitest-fetch-mock"; - -declare global { - var vi: VitestUtils; - var fetchMock: FetchMock; -} - -// Create and configure fetch mock -const fetchMock = createFetchMock(vi); -fetchMock.enableMocks(); - -// Make vi and fetchMock globally available for compatibility -global.vi = vi; -global.fetchMock = fetchMock; From 6cdbaeff7eafcfebd7420e8607b5ba967c20225b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Vachette?= <5880528+michaelva@users.noreply.github.com> Date: Fri, 8 May 2026 10:43:14 +0900 Subject: [PATCH 57/75] handle regions --- packages/create-plugin/src/prompts.ts | 13 +++++- packages/create-plugin/src/template-config.ts | 5 ++- packages/create-plugin/src/transforms.ts | 12 ++++++ templates/plugin-template/.env.app.template | 10 ++--- templates/plugin-template/src/ui/env.ts | 9 ++-- .../plugin-template/template.config.json | 42 +++++++++++++++++++ 6 files changed, 81 insertions(+), 10 deletions(-) diff --git a/packages/create-plugin/src/prompts.ts b/packages/create-plugin/src/prompts.ts index d0bee3677..a2b961ad3 100644 --- a/packages/create-plugin/src/prompts.ts +++ b/packages/create-plugin/src/prompts.ts @@ -4,7 +4,7 @@ import prompts from 'prompts'; import chalk from 'chalk'; import { TemplateConfig } from './template-config.js'; -import { applyTransform, concatValues } from './transforms.js'; +import { applyMapTransform, applyTransform, concatValues } from './transforms.js'; /** * Prompt user for configuration values @@ -101,6 +101,17 @@ export async function promptUser(projectName: string, templateConfig: TemplateCo return String(value); }); answers[targetName] = concatValues(values, derivedConfig.separator || ''); + } else if (derivedConfig.transform === 'map') { + // Look up the source value in a fixed map + if (!derivedConfig.map) { + throw new Error(`"map" transform requires a "map" field`); + } + const sourceField = Array.isArray(derivedConfig.from) ? derivedConfig.from[0] : derivedConfig.from; + const sourceValue = answers[sourceField]; + if (sourceValue === undefined) { + throw new Error(`Source field "${sourceField}" not found in answers`); + } + answers[targetName] = applyMapTransform(String(sourceValue), derivedConfig.map); } else { // Handle single-source transforms const sourceField = Array.isArray(derivedConfig.from) ? derivedConfig.from[0] : derivedConfig.from; diff --git a/packages/create-plugin/src/template-config.ts b/packages/create-plugin/src/template-config.ts index 8dfbbde19..cec08cf48 100644 --- a/packages/create-plugin/src/template-config.ts +++ b/packages/create-plugin/src/template-config.ts @@ -89,11 +89,14 @@ export interface DerivedVariable { /** Source variable name(s) to derive from. Can be a single string or array of strings for concat transform */ from: string | string[]; - /** Transformation to apply: "pascalCase", "camelCase", "kebabCase", "snakeCase", "titleCase", "upperCase", "lowerCase", "concat" */ + /** Transformation to apply: "pascalCase", "camelCase", "kebabCase", "snakeCase", "titleCase", "upperCase", "lowerCase", "concat", "map" */ transform: string; /** Separator for concat transform (default: "") */ separator?: string; + + /** Lookup table for the "map" transform: { sourceValue: derivedValue } */ + map?: Record; } /** diff --git a/packages/create-plugin/src/transforms.ts b/packages/create-plugin/src/transforms.ts index e6309fd57..40e9858ed 100644 --- a/packages/create-plugin/src/transforms.ts +++ b/packages/create-plugin/src/transforms.ts @@ -91,3 +91,15 @@ export function applyTransform(value: string, transform: string): string { export function concatValues(values: string[], separator: string = ''): string { return values.join(separator); } + +/** + * Look up a value in a map (used for the "map" transform) + * @throws Error if the key is not present in the map + */ +export function applyMapTransform(value: string, map: Record): string { + if (!(value in map)) { + const keys = Object.keys(map).join(', '); + throw new Error(`Value "${value}" has no entry in map (expected one of: ${keys})`); + } + return map[value]; +} diff --git a/templates/plugin-template/.env.app.template b/templates/plugin-template/.env.app.template index 68be79f4a..ef6cb0bf1 100644 --- a/templates/plugin-template/.env.app.template +++ b/templates/plugin-template/.env.app.template @@ -3,8 +3,8 @@ # This is public Vite build-time config, not a secret. VITE_APP_NAME={{PROJECT_NAME}} -# Optional Vertesia endpoint overrides. Defaults in src/ui/env.ts target dev-preview. -# Uncomment and set these in .env.local to point at a different environment. -# VITE_STUDIO_URL=https://api.vertesia.io -# VITE_ZENO_URL=https://api.vertesia.io -# VITE_STS_URL=https://sts.vertesia.io +# Vertesia endpoints (region: {{REGION}}). +# Override in .env.app.local to point at a different environment. +VITE_STUDIO_URL={{STUDIO_URL}} +VITE_ZENO_URL={{ZENO_URL}} +VITE_STS_URL={{STS_URL}} diff --git a/templates/plugin-template/src/ui/env.ts b/templates/plugin-template/src/ui/env.ts index 314d98998..33d324523 100644 --- a/templates/plugin-template/src/ui/env.ts +++ b/templates/plugin-template/src/ui/env.ts @@ -1,6 +1,9 @@ import { Env } from "@vertesia/ui/env"; const CONFIG__PLUGIN_TITLE = "Ui Plugin Template"; +const CONFIG__STUDIO_URL = "https://api.vertesia.io"; +const CONFIG__ZENO_URL = "https://api.vertesia.io"; +const CONFIG__STS_URL = "https://sts.vertesia.io"; document.title = CONFIG__PLUGIN_TITLE; @@ -11,8 +14,8 @@ Env.init({ isDocker: true, type: "development", endpoints: { - studio: import.meta.env.VITE_STUDIO_URL ?? "https://api.vertesia.io", - zeno: import.meta.env.VITE_ZENO_URL ?? "https://api.vertesia.io", - sts: import.meta.env.VITE_STS_URL ?? "https://sts.vertesia.io", + studio: import.meta.env.VITE_STUDIO_URL ?? CONFIG__STUDIO_URL, + zeno: import.meta.env.VITE_ZENO_URL ?? CONFIG__ZENO_URL, + sts: import.meta.env.VITE_STS_URL ?? CONFIG__STS_URL, } }); diff --git a/templates/plugin-template/template.config.json b/templates/plugin-template/template.config.json index e8a877c3a..9c98a906b 100644 --- a/templates/plugin-template/template.config.json +++ b/templates/plugin-template/template.config.json @@ -31,6 +31,24 @@ "value": true } ] + }, + { + "type": "select", + "name": "REGION", + "message": "Vertesia region", + "initial": 0, + "choices": [ + { + "title": "Global (us1)", + "description": "Default region — api.vertesia.io / sts.vertesia.io", + "value": "us1" + }, + { + "title": "Europe (eu1)", + "description": "EU region — api.eu1.vertesia.io / sts.eu1.vertesia.io", + "value": "eu1" + } + ] } ], "derived": { @@ -45,6 +63,30 @@ "SERVER_TITLE": { "from": "PROJECT_NAME", "transform": "titleCase" + }, + "STUDIO_URL": { + "from": "REGION", + "transform": "map", + "map": { + "us1": "https://api.vertesia.io", + "eu1": "https://api.eu1.vertesia.io" + } + }, + "ZENO_URL": { + "from": "REGION", + "transform": "map", + "map": { + "us1": "https://api.vertesia.io", + "eu1": "https://api.eu1.vertesia.io" + } + }, + "STS_URL": { + "from": "REGION", + "transform": "map", + "map": { + "us1": "https://sts.vertesia.io", + "eu1": "https://sts.eu1.vertesia.io" + } } }, "files": [ From d0637ecafa17ba4bb015fc1777653f5574a2a029 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Vachette?= <5880528+michaelva@users.noreply.github.com> Date: Fri, 8 May 2026 10:59:21 +0900 Subject: [PATCH 58/75] studio is not public --- templates/plugin-template/Procfile.dev | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 templates/plugin-template/Procfile.dev diff --git a/templates/plugin-template/Procfile.dev b/templates/plugin-template/Procfile.dev deleted file mode 100644 index 3218972b4..000000000 --- a/templates/plugin-template/Procfile.dev +++ /dev/null @@ -1,2 +0,0 @@ -ui: npx vite dev --mode app -server: npm run build:server && node ./lib/server-node.js From 03e190da581d1bb498c28a01927da63934757926 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Vachette?= <5880528+michaelva@users.noreply.github.com> Date: Fri, 8 May 2026 11:20:25 +0900 Subject: [PATCH 59/75] improve package manager handling --- packages/create-plugin/src/index.ts | 10 +++++-- .../create-plugin/src/process-template.ts | 13 +++++++- templates/plugin-template/README.md | 30 +++++++++---------- templates/plugin-template/package.json | 12 ++++---- .../plugin-template/template.config.json | 3 +- 5 files changed, 43 insertions(+), 25 deletions(-) diff --git a/packages/create-plugin/src/index.ts b/packages/create-plugin/src/index.ts index 7db3f87f7..25d9753d4 100644 --- a/packages/create-plugin/src/index.ts +++ b/packages/create-plugin/src/index.ts @@ -8,6 +8,7 @@ */ import chalk from 'chalk'; +import { execSync } from 'child_process'; import { Command } from 'commander'; import fs from 'fs'; import { config, validation } from './configuration.js'; @@ -106,11 +107,16 @@ Documentation: ${config.docsUrl} // Step 5: Prompt user for configuration const answers = await promptUser(projectName, templateConfig, nonInteractive); + // Inject package-manager metadata so templates can reference it via {{PM}}, {{PM_RUN}}, {{PM_VERSION}} + answers.PM = packageManager; + answers.PM_RUN = `${packageManager} run`; + answers.PM_VERSION = execSync(`${packageManager} --version`, { encoding: 'utf8' }).trim(); + // Step 5: Replace variables in files replaceVariables(projectName, templateConfig, answers); // Step 6: Adjust package.json (name and workspace dependencies) - adjustPackageJson(projectName, answers, dev); + adjustPackageJson(projectName, answers, dev, packageManager); // Step 7: Handle conditional removes if (templateConfig.conditionalRemove) { @@ -163,7 +169,7 @@ function showSuccess(projectName: string, packageManager: string, templateName: console.log(chalk.green.bold('✅ Project created successfully!\n')); console.log(chalk.gray('Next steps:\n')); console.log(chalk.cyan(` cd ${projectName}`)); - console.log(chalk.cyan(` ${packageManager} dev`)); + console.log(chalk.cyan(` ${packageManager} run dev`)); console.log(); console.log(chalk.gray(`Documentation: ${config.docsUrl}`)); console.log(chalk.gray(`Template: ${templateName}`)); diff --git a/packages/create-plugin/src/process-template.ts b/packages/create-plugin/src/process-template.ts index 86fdcc2ff..34d51355f 100644 --- a/packages/create-plugin/src/process-template.ts +++ b/packages/create-plugin/src/process-template.ts @@ -172,7 +172,12 @@ export function replaceVariables( * 1. Sets the package name to PROJECT_NAME * 2. Resolves workspace:* dependencies to actual latest versions */ -export function adjustPackageJson(projectName: string, answers: Record, isDev: boolean): void { +export function adjustPackageJson( + projectName: string, + answers: Record, + isDev: boolean, + packageManager: string +): void { console.log(chalk.blue('📝 Adjusting package.json...\n')); const packageJsonPath = path.join(projectName, 'package.json'); @@ -193,6 +198,12 @@ export function adjustPackageJson(projectName: string, answers: Record -- the UI loads with HMR, and the tool server API is available at `/api` on the same port. @@ -62,14 +62,14 @@ Open -- the UI loads with HMR, and the tool server API | Script | Runs | Description | | ------------------- | ------------------------------ | ----------------------------------------------------------- | -| `pnpm dev` | `vite dev --mode app` | Dev server (HTTPS) with UI HMR + tool server API middleware | -| `pnpm build` | `build:server && build:ui` | Full production build (lint runs as prebuild) | -| `pnpm build:server` | `rollup -c` | Compile tool server to `lib/` | -| `pnpm build:ui` | `build:ui:app && build:ui:lib` | Build both UI targets | -| `pnpm build:ui:app` | `vite build --mode app` | Standalone app to `dist/app/` | -| `pnpm build:ui:lib` | `vite build --mode lib` | Plugin library to `dist/lib/plugin.js` | -| `pnpm start` | `build:server && vite preview` | Preview production build locally | -| `pnpm start:vercel` | `vercel dev` | Test Vercel deployment locally | +| `{{PM_RUN}} dev` | `vite dev --mode app` | Dev server (HTTPS) with UI HMR + tool server API middleware | +| `{{PM_RUN}} build` | `rollup -c && vite build (app + lib)` | Full production build (lint runs as prebuild) | +| `{{PM_RUN}} build:server` | `rollup -c` | Compile tool server to `lib/` | +| `{{PM_RUN}} build:ui` | `vite build (app + lib)` | Build both UI targets | +| `{{PM_RUN}} build:ui:app` | `vite build --mode app` | Standalone app to `dist/app/` | +| `{{PM_RUN}} build:ui:lib` | `vite build --mode lib` | Plugin library to `dist/lib/plugin.js` | +| `{{PM_RUN}} start` | `rollup -c && vite preview` | Preview production build locally | +| `{{PM_RUN}} start:vercel` | `vercel dev` | Test Vercel deployment locally | ## Project Structure @@ -105,11 +105,11 @@ plugin-template/ ## Architecture -### Dev Mode (`pnpm dev`) +### Dev Mode (`{{PM_RUN}} dev`) A single Vite dev server runs at `https://localhost:5173`. The `vite-api-server.ts` plugin mounts the Hono tool server as Connect middleware, so the API is served at `/api` on the same port. Tool server source is loaded via Vite's `ssrLoadModule` with hot reload. Import hooks (`?skill`, `?skills`, `?prompt`, `?raw`, `?template`, `?templates`) are handled by `vertesiaImportPlugin`. HTTPS is required for Firebase authentication. -### Preview Mode (`pnpm start`) +### Preview Mode (`{{PM_RUN}} start`) Builds the tool server first, then runs `vite preview` which loads the compiled server from `lib/` as middleware alongside the built UI. Useful for validating production output locally. @@ -486,7 +486,7 @@ To test your local tool server with Vertesia agents (e.g. debug tool execution, ```bash # 1. Start the dev server -pnpm dev +{{PM_RUN}} dev # 2. In a separate terminal, create a tunnel npx cloudflared tunnel --url https://localhost:5173 --no-tls-verify @@ -513,9 +513,9 @@ This lets you set breakpoints, add logging, and iterate on tools/skills while ru **Import hooks are Rollup-only**: `?skill`, `?skills`, `?prompt`, `?raw`, `?template`, `?templates` only work in tool server code (compiled by Rollup). They are not available in UI code (compiled by Vite). -**HTTPS required for dev**: `pnpm dev` uses HTTPS via `@vitejs/plugin-basic-ssl`. Use `-k` flag with curl to skip certificate verification. +**HTTPS required for dev**: `{{PM_RUN}} dev` uses HTTPS via `@vitejs/plugin-basic-ssl`. Use `-k` flag with curl to skip certificate verification. -**TypeScript verification**: Run `pnpm exec tsc --noEmit` to check for compilation errors without building. +**TypeScript verification**: Run `npx tsc --noEmit` to check for compilation errors without building. ## License diff --git a/templates/plugin-template/package.json b/templates/plugin-template/package.json index b07e3c3bb..d62ed9cb5 100644 --- a/templates/plugin-template/package.json +++ b/templates/plugin-template/package.json @@ -12,16 +12,16 @@ "license": "Apache-2.0", "scripts": { "dev": "vite dev --mode app", - "prebuild": "pnpm run lint && tsc --build --noEmit", - "build": "pnpm run build:server && pnpm run build:ui", + "lint": "eslint .", + "prebuild": "eslint . && tsc --build --noEmit", "build:server": "rollup -c", - "build:ui": "pnpm run build:ui:app && pnpm run build:ui:lib", "build:ui:app": "vite build --mode app", "build:ui:lib": "vite build --mode lib", - "start": "pnpm run preview", + "build:ui": "vite build --mode app && vite build --mode lib", + "build": "rollup -c && vite build --mode app && vite build --mode lib", + "preview": "rollup -c && vite preview", + "start": "rollup -c && vite preview", "start:vercel": "vercel dev", - "lint": "eslint .", - "preview": "pnpm run build:server && vite preview", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [ diff --git a/templates/plugin-template/template.config.json b/templates/plugin-template/template.config.json index 9c98a906b..da2788876 100644 --- a/templates/plugin-template/template.config.json +++ b/templates/plugin-template/template.config.json @@ -95,7 +95,8 @@ "src/tool-server/config.ts", "src/ui/env.ts", "src/ui/plugin.tsx", - ".env.app.template" + ".env.app.template", + "README.md" ], "renameFiles": { ".env.app.template": ".env.app" From 5ffe2f1614e941df4942406540af14c0bf5cafe7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Vachette?= <5880528+michaelva@users.noreply.github.com> Date: Fri, 8 May 2026 11:23:46 +0900 Subject: [PATCH 60/75] fix build warning --- templates/plugin-template/src/tool-server/server.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/templates/plugin-template/src/tool-server/server.ts b/templates/plugin-template/src/tool-server/server.ts index 42efc41cd..6fa00549e 100644 --- a/templates/plugin-template/src/tool-server/server.ts +++ b/templates/plugin-template/src/tool-server/server.ts @@ -1,7 +1,8 @@ +import type { Hono } from "hono"; import { createToolServer } from "@vertesia/tools-sdk"; import { ServerConfig } from "./config.js"; // Create server using tools-sdk -const server = createToolServer(ServerConfig); +const server: Hono = createToolServer(ServerConfig); export default server; From cf06907c73c268176e4e7628c5f7f6160e178c0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Vachette?= <5880528+michaelva@users.noreply.github.com> Date: Fri, 8 May 2026 11:58:53 +0900 Subject: [PATCH 61/75] use proper structure for the UI --- templates/plugin-template/CLAUDE.md | 30 +- .../src/ui/ContentObjectsPage.tsx | 452 ------------------ .../src/ui/{app.tsx => app/App.tsx} | 0 .../plugin-template/src/ui/app/README.md | 30 +- .../ui/app/components/InlineFilterButton.tsx | 27 ++ .../src/ui/app/components/SortableHead.tsx | 40 ++ .../src/ui/{ => app}/constants.ts | 0 .../content-objects/ContentObjectsView.tsx | 224 +++++++++ .../components/ContentObjectRow.tsx | 58 +++ .../hooks/useContentObjectsSearch.ts | 113 +++++ .../ui/app/features/content-objects/index.ts | 2 + .../ui/app/features/content-objects/types.ts | 27 ++ .../ui/app/features/content-objects/utils.ts | 28 ++ .../plugin-template/src/ui/app/hooks/.gitkeep | 0 .../ui/{ => app/layouts}/AppSidebarItem.tsx | 0 .../src/ui/{ => app/layouts}/OrgGate.tsx | 0 .../{ => app/layouts}/PluginAccessDenied.tsx | 0 .../src/ui/{ => app/layouts}/PluginLayout.tsx | 0 .../ui/{ => app/layouts}/PluginSidebar.tsx | 2 +- .../src/ui/{ => app/layouts}/PluginTopNav.tsx | 0 .../ui/{pages.tsx => app/pages/ChatPage.tsx} | 23 +- .../src/ui/app/pages/ContentObjectsPage.tsx | 5 + .../src/ui/app/pages/HomePage.tsx | 24 + .../plugin-template/src/ui/app/routes.tsx | 26 + .../src/ui/i18n/locales/en.json | 1 + .../src/ui/i18n/locales/fr.json | 1 + templates/plugin-template/src/ui/main.tsx | 8 +- templates/plugin-template/src/ui/plugin.tsx | 4 +- templates/plugin-template/src/ui/routes.tsx | 25 - 29 files changed, 641 insertions(+), 509 deletions(-) delete mode 100644 templates/plugin-template/src/ui/ContentObjectsPage.tsx rename templates/plugin-template/src/ui/{app.tsx => app/App.tsx} (100%) create mode 100644 templates/plugin-template/src/ui/app/components/InlineFilterButton.tsx create mode 100644 templates/plugin-template/src/ui/app/components/SortableHead.tsx rename templates/plugin-template/src/ui/{ => app}/constants.ts (100%) create mode 100644 templates/plugin-template/src/ui/app/features/content-objects/ContentObjectsView.tsx create mode 100644 templates/plugin-template/src/ui/app/features/content-objects/components/ContentObjectRow.tsx create mode 100644 templates/plugin-template/src/ui/app/features/content-objects/hooks/useContentObjectsSearch.ts create mode 100644 templates/plugin-template/src/ui/app/features/content-objects/index.ts create mode 100644 templates/plugin-template/src/ui/app/features/content-objects/types.ts create mode 100644 templates/plugin-template/src/ui/app/features/content-objects/utils.ts create mode 100644 templates/plugin-template/src/ui/app/hooks/.gitkeep rename templates/plugin-template/src/ui/{ => app/layouts}/AppSidebarItem.tsx (100%) rename templates/plugin-template/src/ui/{ => app/layouts}/OrgGate.tsx (100%) rename templates/plugin-template/src/ui/{ => app/layouts}/PluginAccessDenied.tsx (100%) rename templates/plugin-template/src/ui/{ => app/layouts}/PluginLayout.tsx (100%) rename templates/plugin-template/src/ui/{ => app/layouts}/PluginSidebar.tsx (99%) rename templates/plugin-template/src/ui/{ => app/layouts}/PluginTopNav.tsx (100%) rename templates/plugin-template/src/ui/{pages.tsx => app/pages/ChatPage.tsx} (67%) create mode 100644 templates/plugin-template/src/ui/app/pages/ContentObjectsPage.tsx create mode 100644 templates/plugin-template/src/ui/app/pages/HomePage.tsx create mode 100644 templates/plugin-template/src/ui/app/routes.tsx delete mode 100644 templates/plugin-template/src/ui/routes.tsx diff --git a/templates/plugin-template/CLAUDE.md b/templates/plugin-template/CLAUDE.md index 3792bbf68..a541bc4c1 100644 --- a/templates/plugin-template/CLAUDE.md +++ b/templates/plugin-template/CLAUDE.md @@ -56,9 +56,37 @@ pnpm start # Preview production build (build:server + vite previ | `src/tool-server/settings.ts` | Plugin settings JSON Schema | | `src/ui/plugin.tsx` | Library entry for the Vertesia host app | | `src/ui/main.tsx` | Standalone dev entry (VertesiaShell + AdminApp) | -| `src/ui/routes.tsx` | Route definitions (NestedRouterProvider) | +| `src/ui/app/App.tsx` | App root (NestedRouterProvider) | +| `src/ui/app/routes.tsx` | Route definitions | | `src/ui/index.css` | Tailwind CSS 4 entry with shared styles import | +## UI Directory Structure + +User application code lives under `src/ui/app/`. Place new files according to the layout below — `app/README.md` has the full convention and the "add a feature" recipe. + +```text +src/ui/ +├── main.tsx, plugin.tsx, env.ts, index.css ← bootstrap / wiring (don't add app code here) +├── i18n/ +└── app/ ← user application code + ├── App.tsx, routes.tsx, constants.ts + ├── components/ ← cross-feature shared components (generic primitives) + ├── hooks/ ← cross-feature shared hooks + ├── layouts/ ← plugin chrome (PluginLayout, PluginSidebar, …) + ├── pages/ ← thin route-level wrappers (one file per route) + └── features// + ├── components/, hooks/, types.ts, utils.ts + ├── View.tsx + └── index.ts ← public barrel +``` + +Rules of thumb: + +- A new route → thin component in `app/pages/` that imports its feature. +- Self-contained business logic → `app/features//` with its own components/hooks/types. +- A primitive used by ≥2 features (e.g. a sortable header) → promote to `app/components/`. +- A hook used by ≥2 features → promote to `app/hooks/`. + ## Plugin-Specific Conventions - ESM with `.js` import extensions in tool-server code: `import { x } from "./foo.js"` diff --git a/templates/plugin-template/src/ui/ContentObjectsPage.tsx b/templates/plugin-template/src/ui/ContentObjectsPage.tsx deleted file mode 100644 index ffd377c97..000000000 --- a/templates/plugin-template/src/ui/ContentObjectsPage.tsx +++ /dev/null @@ -1,452 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { ArrowDown, ArrowUp, ArrowUpDown, Filter as FilterIcon } from 'lucide-react'; -import { - Badge, - Button, - type Filter, - type FilterGroup, - type FilterOption, - FilterBar, - FilterBtn, - FilterClear, - FilterProvider, - Input, - Spinner, - TBody, - THead, - Table, - useDebounce, - useFetch, - useIntersectionObserver, - useToast, - VTooltip, -} from '@vertesia/ui/core'; -import { GenericPageNavHeader } from '@vertesia/ui/features'; -import { useUITranslation } from '@vertesia/ui/i18n'; -import { useUserSession } from '@vertesia/ui/session'; -import { - type ContentObjectItem, - type ContentObjectTypeItem, - ContentObjectStatus, -} from '@vertesia/common'; - -const PAGE_SIZE = 50; - -const STATUS_VALUES = Object.values(ContentObjectStatus); - -type SortField = 'name' | 'type' | 'status' | 'updated'; -type SortDir = 'asc' | 'desc'; - -// Elasticsearch text fields can't be sorted directly; use the .keyword sub-field. -// See ElasticsearchIndexManager BASE_INDEX_MAPPING_PROPERTIES. -const SORT_FIELD_MAP: Record = { - name: 'name.keyword', - type: 'type.name', - status: 'status', - updated: 'updated_at', -}; - -type BadgeVariant = - | 'default' - | 'secondary' - | 'destructive' - | 'attention' - | 'success' - | 'info' - | 'done'; - -function statusVariant(status?: ContentObjectStatus): BadgeVariant { - switch (status) { - case ContentObjectStatus.ready: - case ContentObjectStatus.completed: - return 'success'; - case ContentObjectStatus.failed: - return 'destructive'; - case ContentObjectStatus.processing: - case ContentObjectStatus.created: - return 'attention'; - case ContentObjectStatus.archived: - return 'done'; - default: - return 'default'; - } -} - -function getSelectValues(filters: Filter[], name: string): string[] { - const filter = filters.find((f) => f.name === name); - if (!filter || !Array.isArray(filter.value) || filter.value.length === 0) return []; - return filter.value - .map((v) => (typeof v === 'string' ? v : v.value ?? '')) - .filter((v): v is string => Boolean(v)); -} - -export function ContentObjectsPage() { - const { t } = useUITranslation(); - const { client } = useUserSession(); - const toast = useToast(); - - const [query, setQuery] = useState(''); - const [filters, setFilters] = useState([]); - const [sortField, setSortField] = useState('updated'); - const [sortDir, setSortDir] = useState('desc'); - const [types, setTypes] = useState([]); - const [moreItems, setMoreItems] = useState([]); - const [hasMore, setHasMore] = useState(false); - const [isLoadingMore, setIsLoadingMore] = useState(false); - - const debouncedQuery = useDebounce(query, 300); - - const loadMoreRef = useRef(null); - - const buildQuery = useCallback(() => { - const typeIds = getSelectValues(filters, 'type'); - const statusValues = getSelectValues(filters, 'status'); - const trimmed = debouncedQuery.trim(); - return { - ...(trimmed ? { full_text: trimmed } : {}), - ...(typeIds.length ? { types: typeIds } : {}), - ...(statusValues.length ? { status: statusValues } : {}), - }; - }, [debouncedQuery, filters]); - - const sortPayload = useMemo( - () => [{ field: SORT_FIELD_MAP[sortField], order: sortDir }], - [sortField, sortDir], - ); - - const { data: firstPage, isLoading } = useFetch( - async () => { - const result = await client.objects.search({ - query: buildQuery(), - limit: PAGE_SIZE, - offset: 0, - sort: sortPayload, - }); - return result.results ?? []; - }, - { - deps: [debouncedQuery, filters, sortField, sortDir], - onSuccess: (results) => { - setMoreItems([]); - setHasMore(results.length >= PAGE_SIZE); - }, - onError: (err) => { - console.error('Content object search failed:', err); - toast({ status: 'error', title: t('objects.searchError') }); - }, - }, - ); - - useEffect(() => { - client.store.types - .list({ limit: 200 }) - .then(setTypes) - .catch((err) => console.error('Failed to load content types:', err)); - }, [client]); - - const items = useMemo(() => [...(firstPage ?? []), ...moreItems], [firstPage, moreItems]); - - const loadMore = useCallback(() => { - if (isLoadingMore || !hasMore || isLoading) return; - setIsLoadingMore(true); - const offset = items.length; - client.objects - .search({ - query: buildQuery(), - limit: PAGE_SIZE, - offset, - sort: sortPayload, - }) - .then((result) => { - const results = result.results ?? []; - setMoreItems((prev) => [...prev, ...results]); - setHasMore(results.length >= PAGE_SIZE); - }) - .catch((err) => { - console.error('Load more failed:', err); - toast({ status: 'error', title: t('objects.searchError') }); - }) - .finally(() => setIsLoadingMore(false)); - }, [ - client, - buildQuery, - sortPayload, - items.length, - isLoadingMore, - isLoading, - hasMore, - toast, - t, - ]); - - useIntersectionObserver(loadMoreRef, loadMore, { - threshold: 0.1, - deps: [loadMore], - }); - - const handleSort = useCallback( - (field: SortField) => { - if (sortField === field) { - setSortDir((current) => (current === 'asc' ? 'desc' : 'asc')); - return; - } - setSortField(field); - setSortDir('asc'); - }, - [sortField], - ); - - const addFilterValue = useCallback( - (name: 'type' | 'status', value: string, label: string) => { - const placeholder = - name === 'type' ? t('objects.filterType') : t('objects.filterStatus'); - const newOption: FilterOption = { value, label }; - setFilters((prev) => { - const existing = prev.find((f) => f.name === name); - if (!existing) { - return [ - ...prev, - { - name, - placeholder, - type: 'select', - multiple: true, - value: [newOption], - }, - ]; - } - const currentValues = Array.isArray(existing.value) ? existing.value : []; - const alreadyHas = currentValues.some( - (v) => (typeof v === 'string' ? v : v.value) === value, - ); - if (alreadyHas) return prev; - return prev.map((f) => - f === existing - ? { ...f, value: [...(f.value as FilterOption[]), newOption] } - : f, - ); - }); - }, - [t], - ); - - const filterGroups: FilterGroup[] = useMemo( - () => [ - { - name: 'type', - placeholder: t('objects.filterType'), - type: 'select', - multiple: true, - options: [...types] - .map((typ) => ({ value: typ.id, label: typ.name })) - .sort((a, b) => a.label.localeCompare(b.label)), - }, - { - name: 'status', - placeholder: t('objects.filterStatus'), - type: 'select', - multiple: true, - options: STATUS_VALUES.map((s) => ({ - value: s, - label: t(`objects.status.${s}`), - })).sort((a, b) => a.label.localeCompare(b.label)), - }, - ], - [types, t], - ); - - const showLoadMoreSpinner = isLoadingMore; - const showEmpty = !isLoading && items.length === 0; - - return ( -
- -
- -
- - - -
- -
- -
- - - - - - - - - - - {items.map((item) => ( - - ))} - -
- {showLoadMoreSpinner && ( -
- -
- )} -
- {showEmpty && ( -
- {t('objects.empty')} -
- )} -
-
-
- ); -} - -interface SortableHeadProps { - field: SortField; - label: string; - activeField: SortField; - direction: SortDir; - onSort: (field: SortField) => void; -} - -function SortableHead({ field, label, activeField, direction, onSort }: SortableHeadProps) { - const isActive = activeField === field; - const Icon = isActive ? (direction === 'asc' ? ArrowUp : ArrowDown) : ArrowUpDown; - return ( - onSort(field)} - > -
- {label} -
- - ); -} - -interface InlineFilterButtonProps { - tooltip: string; - hoverClass: string; - onClick: () => void; -} - -function InlineFilterButton({ tooltip, hoverClass, onClick }: InlineFilterButtonProps) { - return ( - - - - ); -} - -interface ContentObjectRowProps { - item: ContentObjectItem; - t: (key: string, opts?: Record) => string; - onAddFilter: (name: 'type' | 'status', value: string, label: string) => void; -} - -function ContentObjectRow({ item, t, onAddFilter }: ContentObjectRowProps) { - const updated = item.updated_at ? new Date(item.updated_at).toLocaleString() : '—'; - const typeName = item.type?.name; - const typeId = item.type && 'id' in item.type ? item.type.id : undefined; - const statusLabel = item.status ? t(`objects.status.${item.status}`) : '—'; - - return ( - - -
- {item.name || item.id} - {item.description && ( - - {item.description} - - )} -
- - -
- {typeName ?? '—'} - {typeId && typeName && ( - onAddFilter('type', typeId, typeName)} - /> - )} -
- - -
- {statusLabel} - {item.status && ( - onAddFilter('status', item.status, statusLabel)} - /> - )} -
- - {updated} - - ); -} diff --git a/templates/plugin-template/src/ui/app.tsx b/templates/plugin-template/src/ui/app/App.tsx similarity index 100% rename from templates/plugin-template/src/ui/app.tsx rename to templates/plugin-template/src/ui/app/App.tsx diff --git a/templates/plugin-template/src/ui/app/README.md b/templates/plugin-template/src/ui/app/README.md index d679dbf2a..a4abcde8a 100644 --- a/templates/plugin-template/src/ui/app/README.md +++ b/templates/plugin-template/src/ui/app/README.md @@ -1,6 +1,32 @@ -# Application Components +# App -Put your components and business logic here +User application code lives here. Layout follows the standard React app structure: +```text +app/ +├── App.tsx # Root component (renders NestedRouterProvider) +├── routes.tsx # Route definitions +├── constants.ts # Shared constants (interaction names, etc.) +├── components/ # Shared UI components (used across features/pages) +├── features/ # Business logic grouped by feature +│ └── / +│ ├── components/ # Feature-only components +│ ├── hooks/ # Feature-only hooks +│ ├── types.ts # Feature types +│ ├── utils.ts # Feature helpers +│ ├── View.tsx +│ └── index.ts # Barrel export +├── hooks/ # Cross-feature custom hooks +├── layouts/ # Plugin chrome (PluginLayout, PluginSidebar, …) +└── pages/ # Route-level components — thin wrappers around features +``` +## Adding a feature +1. Create `app/features//`. +2. Build it as a self-contained module with `components/`, `hooks/`, `types.ts`, `utils.ts`, etc. +3. Export the entry view and any public types via `index.ts`. +4. Add a thin route component in `app/pages/Page.tsx` that imports from `features/`. +5. Wire the route in `app/routes.tsx`. + +For UI patterns and component conventions, see the `vertesia-ui` skill. diff --git a/templates/plugin-template/src/ui/app/components/InlineFilterButton.tsx b/templates/plugin-template/src/ui/app/components/InlineFilterButton.tsx new file mode 100644 index 000000000..fb9a54e3d --- /dev/null +++ b/templates/plugin-template/src/ui/app/components/InlineFilterButton.tsx @@ -0,0 +1,27 @@ +import { Filter as FilterIcon } from 'lucide-react'; +import { Button, VTooltip } from '@vertesia/ui/core'; + +interface InlineFilterButtonProps { + tooltip: string; + hoverClass: string; + onClick: () => void; +} + +export function InlineFilterButton({ tooltip, hoverClass, onClick }: InlineFilterButtonProps) { + return ( + + + + ); +} diff --git a/templates/plugin-template/src/ui/app/components/SortableHead.tsx b/templates/plugin-template/src/ui/app/components/SortableHead.tsx new file mode 100644 index 000000000..77c946ae6 --- /dev/null +++ b/templates/plugin-template/src/ui/app/components/SortableHead.tsx @@ -0,0 +1,40 @@ +import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react'; + +export type SortDir = 'asc' | 'desc'; + +interface SortableHeadProps { + field: TField; + label: string; + activeField: TField; + direction: SortDir; + onSort: (field: TField) => void; +} + +export function SortableHead({ + field, + label, + activeField, + direction, + onSort, +}: SortableHeadProps) { + const isActive = activeField === field; + const Icon = isActive ? (direction === 'asc' ? ArrowUp : ArrowDown) : ArrowUpDown; + return ( + onSort(field)} + > +
+ {label} +
+ + ); +} diff --git a/templates/plugin-template/src/ui/constants.ts b/templates/plugin-template/src/ui/app/constants.ts similarity index 100% rename from templates/plugin-template/src/ui/constants.ts rename to templates/plugin-template/src/ui/app/constants.ts diff --git a/templates/plugin-template/src/ui/app/features/content-objects/ContentObjectsView.tsx b/templates/plugin-template/src/ui/app/features/content-objects/ContentObjectsView.tsx new file mode 100644 index 000000000..a3332b002 --- /dev/null +++ b/templates/plugin-template/src/ui/app/features/content-objects/ContentObjectsView.tsx @@ -0,0 +1,224 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { RefreshCw } from 'lucide-react'; +import { + Button, + type Filter, + type FilterGroup, + type FilterOption, + FilterBar, + FilterBtn, + FilterClear, + FilterProvider, + Input, + Spinner, + TBody, + THead, + Table, + useDebounce, + useIntersectionObserver, +} from '@vertesia/ui/core'; +import { GenericPageNavHeader } from '@vertesia/ui/features'; +import { useUITranslation } from '@vertesia/ui/i18n'; +import { useUserSession } from '@vertesia/ui/session'; +import type { ContentObjectTypeItem } from '@vertesia/common'; +import { SortableHead, type SortDir } from '../../components/SortableHead'; +import { ContentObjectRow } from './components/ContentObjectRow'; +import { useContentObjectsSearch } from './hooks/useContentObjectsSearch'; +import { STATUS_VALUES, type FilterableField, type SortField } from './types'; + +export function ContentObjectsView() { + const { t } = useUITranslation(); + const { client } = useUserSession(); + + const [query, setQuery] = useState(''); + const [filters, setFilters] = useState([]); + const [sortField, setSortField] = useState('updated'); + const [sortDir, setSortDir] = useState('desc'); + const [types, setTypes] = useState([]); + + const debouncedQuery = useDebounce(query, 300); + + const loadMoreRef = useRef(null); + + const { items, isLoading, isLoadingMore, loadMore, refetch } = + useContentObjectsSearch({ debouncedQuery, filters, sortField, sortDir }); + + useEffect(() => { + client.store.types + .list({ limit: 200 }) + .then(setTypes) + .catch((err) => console.error('Failed to load content types:', err)); + }, [client]); + + useIntersectionObserver(loadMoreRef, loadMore, { + threshold: 0.1, + deps: [loadMore], + }); + + const handleSort = useCallback( + (field: SortField) => { + if (sortField === field) { + setSortDir((current) => (current === 'asc' ? 'desc' : 'asc')); + return; + } + setSortField(field); + setSortDir('asc'); + }, + [sortField], + ); + + const addFilterValue = useCallback( + (name: FilterableField, value: string, label: string) => { + const placeholder = + name === 'type' ? t('objects.filterType') : t('objects.filterStatus'); + const newOption: FilterOption = { value, label }; + setFilters((prev) => { + const existing = prev.find((f) => f.name === name); + if (!existing) { + return [ + ...prev, + { + name, + placeholder, + type: 'select', + multiple: true, + value: [newOption], + }, + ]; + } + const currentValues = Array.isArray(existing.value) ? existing.value : []; + const alreadyHas = currentValues.some( + (v) => (typeof v === 'string' ? v : v.value) === value, + ); + if (alreadyHas) return prev; + return prev.map((f) => + f === existing + ? { ...f, value: [...(f.value as FilterOption[]), newOption] } + : f, + ); + }); + }, + [t], + ); + + const filterGroups: FilterGroup[] = useMemo( + () => [ + { + name: 'type', + placeholder: t('objects.filterType'), + type: 'select', + multiple: true, + options: [...types] + .map((typ) => ({ value: typ.id, label: typ.name })) + .sort((a, b) => a.label.localeCompare(b.label)), + }, + { + name: 'status', + placeholder: t('objects.filterStatus'), + type: 'select', + multiple: true, + options: STATUS_VALUES.map((s) => ({ + value: s, + label: t(`objects.status.${s}`), + })).sort((a, b) => a.label.localeCompare(b.label)), + }, + ], + [types, t], + ); + + const showEmpty = !isLoading && items.length === 0; + + return ( +
+ +
+ +
+
+ +
+ + + +
+ +
+ +
+ + + + + + + + + + + {items.map((item) => ( + + ))} + +
+ {isLoadingMore && ( +
+ +
+ )} +
+ {showEmpty && ( +
+ {t('objects.empty')} +
+ )} +
+
+
+ ); +} diff --git a/templates/plugin-template/src/ui/app/features/content-objects/components/ContentObjectRow.tsx b/templates/plugin-template/src/ui/app/features/content-objects/components/ContentObjectRow.tsx new file mode 100644 index 000000000..665ebb832 --- /dev/null +++ b/templates/plugin-template/src/ui/app/features/content-objects/components/ContentObjectRow.tsx @@ -0,0 +1,58 @@ +import { Badge } from '@vertesia/ui/core'; +import type { ContentObjectItem } from '@vertesia/common'; +import { InlineFilterButton } from '../../../components/InlineFilterButton'; +import type { FilterableField } from '../types'; +import { statusVariant } from '../utils'; + +interface ContentObjectRowProps { + item: ContentObjectItem; + t: (key: string, opts?: Record) => string; + onAddFilter: (name: FilterableField, value: string, label: string) => void; +} + +export function ContentObjectRow({ item, t, onAddFilter }: ContentObjectRowProps) { + const updated = item.updated_at ? new Date(item.updated_at).toLocaleString() : '—'; + const typeName = item.type?.name; + const typeId = item.type && 'id' in item.type ? item.type.id : undefined; + const statusLabel = item.status ? t(`objects.status.${item.status}`) : '—'; + + return ( + + +
+ {item.name || item.id} + {item.description && ( + + {item.description} + + )} +
+ + +
+ {typeName ?? '—'} + {typeId && typeName && ( + onAddFilter('type', typeId, typeName)} + /> + )} +
+ + +
+ {statusLabel} + {item.status && ( + onAddFilter('status', item.status, statusLabel)} + /> + )} +
+ + {updated} + + ); +} diff --git a/templates/plugin-template/src/ui/app/features/content-objects/hooks/useContentObjectsSearch.ts b/templates/plugin-template/src/ui/app/features/content-objects/hooks/useContentObjectsSearch.ts new file mode 100644 index 000000000..ba6bd0b0e --- /dev/null +++ b/templates/plugin-template/src/ui/app/features/content-objects/hooks/useContentObjectsSearch.ts @@ -0,0 +1,113 @@ +import { useCallback, useMemo, useState } from 'react'; +import { useFetch, useToast, type Filter } from '@vertesia/ui/core'; +import { useUITranslation } from '@vertesia/ui/i18n'; +import { useUserSession } from '@vertesia/ui/session'; +import type { ContentObjectItem } from '@vertesia/common'; +import type { SortDir } from '../../../components/SortableHead'; +import { PAGE_SIZE, SORT_FIELD_MAP, type SortField } from '../types'; +import { getSelectValues } from '../utils'; + +interface UseContentObjectsSearchArgs { + debouncedQuery: string; + filters: Filter[]; + sortField: SortField; + sortDir: SortDir; +} + +export function useContentObjectsSearch({ + debouncedQuery, + filters, + sortField, + sortDir, +}: UseContentObjectsSearchArgs) { + const { t } = useUITranslation(); + const { client } = useUserSession(); + const toast = useToast(); + + const [moreItems, setMoreItems] = useState([]); + const [hasMore, setHasMore] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); + + const buildQuery = useCallback(() => { + const typeIds = getSelectValues(filters, 'type'); + const statusValues = getSelectValues(filters, 'status'); + const trimmed = debouncedQuery.trim(); + return { + ...(trimmed ? { full_text: trimmed } : {}), + ...(typeIds.length ? { types: typeIds } : {}), + ...(statusValues.length ? { status: statusValues } : {}), + }; + }, [debouncedQuery, filters]); + + const sortPayload = useMemo( + () => [{ field: SORT_FIELD_MAP[sortField], order: sortDir }], + [sortField, sortDir], + ); + + const { data: firstPage, isLoading, refetch } = useFetch( + async () => { + const result = await client.objects.search({ + query: buildQuery(), + limit: PAGE_SIZE, + offset: 0, + sort: sortPayload, + }); + return result.results ?? []; + }, + { + deps: [debouncedQuery, filters, sortField, sortDir], + onSuccess: (results) => { + setMoreItems([]); + setHasMore(results.length >= PAGE_SIZE); + }, + onError: (err) => { + console.error('Content object search failed:', err); + toast({ status: 'error', title: t('objects.searchError') }); + }, + }, + ); + + const items = useMemo(() => [...(firstPage ?? []), ...moreItems], [firstPage, moreItems]); + + const loadMore = useCallback(() => { + if (isLoadingMore || !hasMore || isLoading) return; + setIsLoadingMore(true); + const offset = items.length; + client.objects + .search({ + query: buildQuery(), + limit: PAGE_SIZE, + offset, + sort: sortPayload, + }) + .then((result) => { + const results = result.results ?? []; + setMoreItems((prev) => [...prev, ...results]); + setHasMore(results.length >= PAGE_SIZE); + }) + .catch((err) => { + console.error('Load more failed:', err); + toast({ status: 'error', title: t('objects.searchError') }); + }) + .finally(() => setIsLoadingMore(false)); + }, [ + client, + buildQuery, + sortPayload, + items.length, + isLoadingMore, + isLoading, + hasMore, + toast, + t, + ]); + + return { + items, + isLoading, + isLoadingMore, + hasMore, + loadMore, + refetch, + }; +} diff --git a/templates/plugin-template/src/ui/app/features/content-objects/index.ts b/templates/plugin-template/src/ui/app/features/content-objects/index.ts new file mode 100644 index 000000000..61ae38626 --- /dev/null +++ b/templates/plugin-template/src/ui/app/features/content-objects/index.ts @@ -0,0 +1,2 @@ +export { ContentObjectsView } from './ContentObjectsView'; +export type { SortField, FilterableField } from './types'; diff --git a/templates/plugin-template/src/ui/app/features/content-objects/types.ts b/templates/plugin-template/src/ui/app/features/content-objects/types.ts new file mode 100644 index 000000000..59a7fdd98 --- /dev/null +++ b/templates/plugin-template/src/ui/app/features/content-objects/types.ts @@ -0,0 +1,27 @@ +import { ContentObjectStatus } from '@vertesia/common'; + +export type SortField = 'name' | 'type' | 'status' | 'updated'; + +// Elasticsearch text fields can't be sorted directly; use the .keyword sub-field. +// See ElasticsearchIndexManager BASE_INDEX_MAPPING_PROPERTIES. +export const SORT_FIELD_MAP: Record = { + name: 'name.keyword', + type: 'type.name', + status: 'status', + updated: 'updated_at', +}; + +export const PAGE_SIZE = 50; + +export const STATUS_VALUES = Object.values(ContentObjectStatus); + +export type FilterableField = 'type' | 'status'; + +export type BadgeVariant = + | 'default' + | 'secondary' + | 'destructive' + | 'attention' + | 'success' + | 'info' + | 'done'; diff --git a/templates/plugin-template/src/ui/app/features/content-objects/utils.ts b/templates/plugin-template/src/ui/app/features/content-objects/utils.ts new file mode 100644 index 000000000..f10ea3c74 --- /dev/null +++ b/templates/plugin-template/src/ui/app/features/content-objects/utils.ts @@ -0,0 +1,28 @@ +import { ContentObjectStatus } from '@vertesia/common'; +import type { Filter } from '@vertesia/ui/core'; +import type { BadgeVariant } from './types'; + +export function statusVariant(status?: ContentObjectStatus): BadgeVariant { + switch (status) { + case ContentObjectStatus.ready: + case ContentObjectStatus.completed: + return 'success'; + case ContentObjectStatus.failed: + return 'destructive'; + case ContentObjectStatus.processing: + case ContentObjectStatus.created: + return 'attention'; + case ContentObjectStatus.archived: + return 'done'; + default: + return 'default'; + } +} + +export function getSelectValues(filters: Filter[], name: string): string[] { + const filter = filters.find((f) => f.name === name); + if (!filter || !Array.isArray(filter.value) || filter.value.length === 0) return []; + return filter.value + .map((v) => (typeof v === 'string' ? v : v.value ?? '')) + .filter((v): v is string => Boolean(v)); +} diff --git a/templates/plugin-template/src/ui/app/hooks/.gitkeep b/templates/plugin-template/src/ui/app/hooks/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/templates/plugin-template/src/ui/AppSidebarItem.tsx b/templates/plugin-template/src/ui/app/layouts/AppSidebarItem.tsx similarity index 100% rename from templates/plugin-template/src/ui/AppSidebarItem.tsx rename to templates/plugin-template/src/ui/app/layouts/AppSidebarItem.tsx diff --git a/templates/plugin-template/src/ui/OrgGate.tsx b/templates/plugin-template/src/ui/app/layouts/OrgGate.tsx similarity index 100% rename from templates/plugin-template/src/ui/OrgGate.tsx rename to templates/plugin-template/src/ui/app/layouts/OrgGate.tsx diff --git a/templates/plugin-template/src/ui/PluginAccessDenied.tsx b/templates/plugin-template/src/ui/app/layouts/PluginAccessDenied.tsx similarity index 100% rename from templates/plugin-template/src/ui/PluginAccessDenied.tsx rename to templates/plugin-template/src/ui/app/layouts/PluginAccessDenied.tsx diff --git a/templates/plugin-template/src/ui/PluginLayout.tsx b/templates/plugin-template/src/ui/app/layouts/PluginLayout.tsx similarity index 100% rename from templates/plugin-template/src/ui/PluginLayout.tsx rename to templates/plugin-template/src/ui/app/layouts/PluginLayout.tsx diff --git a/templates/plugin-template/src/ui/PluginSidebar.tsx b/templates/plugin-template/src/ui/app/layouts/PluginSidebar.tsx similarity index 99% rename from templates/plugin-template/src/ui/PluginSidebar.tsx rename to templates/plugin-template/src/ui/app/layouts/PluginSidebar.tsx index 5dca0b78e..9a3c87e2e 100644 --- a/templates/plugin-template/src/ui/PluginSidebar.tsx +++ b/templates/plugin-template/src/ui/app/layouts/PluginSidebar.tsx @@ -7,7 +7,7 @@ import { useUserSession } from '@vertesia/ui/session'; import { Database, HomeIcon, MessageSquare, PlusCircle } from 'lucide-react'; import type { AgentRunResponse, WorkflowRun } from '@vertesia/common'; import { AppSidebarItem } from './AppSidebarItem'; -import { ASSISTANT_INTERACTION } from './constants'; +import { ASSISTANT_INTERACTION } from '../constants'; function toWorkflowRun(run: AgentRunResponse): WorkflowRun { const isAgentRun = run.run_kind === 'agent'; diff --git a/templates/plugin-template/src/ui/PluginTopNav.tsx b/templates/plugin-template/src/ui/app/layouts/PluginTopNav.tsx similarity index 100% rename from templates/plugin-template/src/ui/PluginTopNav.tsx rename to templates/plugin-template/src/ui/app/layouts/PluginTopNav.tsx diff --git a/templates/plugin-template/src/ui/pages.tsx b/templates/plugin-template/src/ui/app/pages/ChatPage.tsx similarity index 67% rename from templates/plugin-template/src/ui/pages.tsx rename to templates/plugin-template/src/ui/app/pages/ChatPage.tsx index a0ec4386c..86f4998ef 100644 --- a/templates/plugin-template/src/ui/pages.tsx +++ b/templates/plugin-template/src/ui/app/pages/ChatPage.tsx @@ -1,31 +1,10 @@ import { useCallback } from "react"; -import { Bot } from "lucide-react"; import { ModernAgentConversation } from "@vertesia/ui/features"; -import { Button } from "@vertesia/ui/core"; import { useNavigate, useParams } from "@vertesia/ui/router"; import { useUserSession } from "@vertesia/ui/session"; import { useUITranslation } from "@vertesia/ui/i18n"; import type { CreateAgentRunPayload } from "@vertesia/common"; -import { ASSISTANT_INTERACTION } from "./constants"; - -export function HomePage() { - const { user } = useUserSession(); - const { t } = useUITranslation(); - const navigate = useNavigate(); - - return ( -
-

{t('nav.welcome', { name: user?.name || user?.email })}

-

- {t('nav.templateDescription')} -

- -
- ); -} +import { ASSISTANT_INTERACTION } from "../constants"; export function ChatPage() { const { t } = useUITranslation(); diff --git a/templates/plugin-template/src/ui/app/pages/ContentObjectsPage.tsx b/templates/plugin-template/src/ui/app/pages/ContentObjectsPage.tsx new file mode 100644 index 000000000..42976ddd6 --- /dev/null +++ b/templates/plugin-template/src/ui/app/pages/ContentObjectsPage.tsx @@ -0,0 +1,5 @@ +import { ContentObjectsView } from '../features/content-objects'; + +export function ContentObjectsPage() { + return ; +} diff --git a/templates/plugin-template/src/ui/app/pages/HomePage.tsx b/templates/plugin-template/src/ui/app/pages/HomePage.tsx new file mode 100644 index 000000000..85ac728d6 --- /dev/null +++ b/templates/plugin-template/src/ui/app/pages/HomePage.tsx @@ -0,0 +1,24 @@ +import { Bot } from "lucide-react"; +import { Button } from "@vertesia/ui/core"; +import { useNavigate } from "@vertesia/ui/router"; +import { useUserSession } from "@vertesia/ui/session"; +import { useUITranslation } from "@vertesia/ui/i18n"; + +export function HomePage() { + const { user } = useUserSession(); + const { t } = useUITranslation(); + const navigate = useNavigate(); + + return ( +
+

{t('nav.welcome', { name: user?.name || user?.email })}

+

+ {t('nav.templateDescription')} +

+ +
+ ); +} diff --git a/templates/plugin-template/src/ui/app/routes.tsx b/templates/plugin-template/src/ui/app/routes.tsx new file mode 100644 index 000000000..91f1c4344 --- /dev/null +++ b/templates/plugin-template/src/ui/app/routes.tsx @@ -0,0 +1,26 @@ +import { ChatPage } from "./pages/ChatPage"; +import { ContentObjectsPage } from "./pages/ContentObjectsPage"; +import { HomePage } from "./pages/HomePage"; + +export const routes = [ + { + path: '/', + Component: () => , + }, + { + path: '/objects', + Component: () => , + }, + { + path: '/chat', + Component: () => , + }, + { + path: '/chat/:agentRunId', + Component: () => , + }, + { + path: '*', + Component: () =>
Not found
, + } +]; diff --git a/templates/plugin-template/src/ui/i18n/locales/en.json b/templates/plugin-template/src/ui/i18n/locales/en.json index d8ad8c225..99b9a1e45 100644 --- a/templates/plugin-template/src/ui/i18n/locales/en.json +++ b/templates/plugin-template/src/ui/i18n/locales/en.json @@ -25,6 +25,7 @@ "objects.filterByValue": "Filter by {{value}}", "objects.filterStatus": "Status", "objects.filterType": "Type", + "objects.refresh": "Refresh", "objects.searchError": "Failed to search content objects.", "objects.searchPlaceholder": "Search content objects...", "objects.status.archived": "Archived", diff --git a/templates/plugin-template/src/ui/i18n/locales/fr.json b/templates/plugin-template/src/ui/i18n/locales/fr.json index 1ba464bcf..32dcbc189 100644 --- a/templates/plugin-template/src/ui/i18n/locales/fr.json +++ b/templates/plugin-template/src/ui/i18n/locales/fr.json @@ -25,6 +25,7 @@ "objects.filterByValue": "Filtrer par {{value}}", "objects.filterStatus": "Statut", "objects.filterType": "Type", + "objects.refresh": "Rafraîchir", "objects.searchError": "Échec de la recherche d'objets de contenu.", "objects.searchPlaceholder": "Rechercher des objets de contenu...", "objects.status.archived": "Archivé", diff --git a/templates/plugin-template/src/ui/main.tsx b/templates/plugin-template/src/ui/main.tsx index 4ebd9ff9d..3e200f5ce 100644 --- a/templates/plugin-template/src/ui/main.tsx +++ b/templates/plugin-template/src/ui/main.tsx @@ -7,12 +7,12 @@ import './index.css' // initialize dev environment import { AdminApp } from '@vertesia/tools-admin-ui' import { RouterProvider, type Route } from '@vertesia/ui/router' -import { App } from './app' +import { App } from './app/App' import { setUsePluginAssets } from './assets' import "./env" -import { OrgGate } from './OrgGate' -import { PluginAccessDenied } from './PluginAccessDenied' -import { PluginLayout } from './PluginLayout' +import { OrgGate } from './app/layouts/OrgGate' +import { PluginAccessDenied } from './app/layouts/PluginAccessDenied' +import { PluginLayout } from './app/layouts/PluginLayout' setUsePluginAssets(false); diff --git a/templates/plugin-template/src/ui/plugin.tsx b/templates/plugin-template/src/ui/plugin.tsx index 8369a7eb0..5b4b129fb 100644 --- a/templates/plugin-template/src/ui/plugin.tsx +++ b/templates/plugin-template/src/ui/plugin.tsx @@ -1,7 +1,7 @@ import { PortalContainerProvider } from "@vertesia/ui/core"; import "./i18n"; // register plugin-specific translations -import { App } from "./app"; -import { PluginLayout } from "./PluginLayout"; +import { App } from "./app/App"; +import { PluginLayout } from "./app/layouts/PluginLayout"; /** * Export the plugin component. diff --git a/templates/plugin-template/src/ui/routes.tsx b/templates/plugin-template/src/ui/routes.tsx deleted file mode 100644 index 3a4199b9c..000000000 --- a/templates/plugin-template/src/ui/routes.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { ContentObjectsPage } from "./ContentObjectsPage"; -import { ChatPage, HomePage } from "./pages"; - -export const routes = [ - { - path: '/', - Component: HomePage, - }, - { - path: '/objects', - Component: ContentObjectsPage, - }, - { - path: '/chat', - Component: ChatPage, - }, - { - path: '/chat/:agentRunId', - Component: ChatPage, - }, - { - path: '*', - Component: () =>
Not found
, - } -]; From 90d2ed713bff7ee06b78b815505ef2b2b759d66c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Vachette?= <5880528+michaelva@users.noreply.github.com> Date: Fri, 8 May 2026 14:34:44 +0900 Subject: [PATCH 62/75] table improvement --- .../.claude/skills/vertesia-ui/SKILL.md | 33 ++- .../references/generic-table-pattern.md | 113 ++++++- templates/plugin-template/src/ui/app/App.tsx | 5 +- .../ContentObjectDetailView.tsx | 62 ++++ .../ContentObjectsListStateContext.ts | 48 +++ .../ContentObjectsListStateProvider.tsx | 185 ++++++++++++ .../content-objects/ContentObjectsView.tsx | 280 ++++++++++++++---- .../components/ContentObjectRow.tsx | 16 +- .../hooks/useContentObjectsSearch.ts | 113 ------- .../ui/app/features/content-objects/index.ts | 3 + .../ui/app/pages/ContentObjectDetailPage.tsx | 5 + .../plugin-template/src/ui/app/routes.tsx | 5 + .../src/ui/i18n/locales/en.json | 2 + .../src/ui/i18n/locales/fr.json | 2 + 14 files changed, 693 insertions(+), 179 deletions(-) create mode 100644 templates/plugin-template/src/ui/app/features/content-objects/ContentObjectDetailView.tsx create mode 100644 templates/plugin-template/src/ui/app/features/content-objects/ContentObjectsListStateContext.ts create mode 100644 templates/plugin-template/src/ui/app/features/content-objects/ContentObjectsListStateProvider.tsx delete mode 100644 templates/plugin-template/src/ui/app/features/content-objects/hooks/useContentObjectsSearch.ts create mode 100644 templates/plugin-template/src/ui/app/pages/ContentObjectDetailPage.tsx diff --git a/templates/plugin-template/.claude/skills/vertesia-ui/SKILL.md b/templates/plugin-template/.claude/skills/vertesia-ui/SKILL.md index d6f7c8c59..551cccda9 100644 --- a/templates/plugin-template/.claude/skills/vertesia-ui/SKILL.md +++ b/templates/plugin-template/.claude/skills/vertesia-ui/SKILL.md @@ -327,11 +327,10 @@ const { isOpen, toggleMobile } = useSidebarToggle(); ```tsx Home, - Current Page, + Current Page, ]} actions={} /> @@ -339,6 +338,34 @@ const { isOpen, toggleMobile } = useSidebarToggle(); Prefer `GenericPageNavHeader` over a local page-header abstraction when it fits the page. +#### Breadcrumb rules + +These bite repeatedly — apply them on every page that uses `GenericPageNavHeader`. + +1. **Always pass `useDynamicBreadcrumbs={false}`.** The default (`true`) reads `window.history.state?.historyChain` and falls back to URL path inference. Both produce surprises: stale entries from a previous detail visit (e.g. "App > App") leak onto a top-level list, and URL inference capitalizes raw segments (e.g. "Objects" instead of your i18n label "Content Objects"). Explicit beats inferred. + +2. **Don't combine `title=` with breadcrumbs for list/detail flows.** `title=` renders a big bold heading *under* the breadcrumb row, which on a detail page reads as a stacked title — not a breadcrumb. The real breadcrumb pattern is to put **all** segments in `breadcrumbs={[...]}` (parent → current) and omit `title=` entirely. composable-ui's `ContentObjectView` follows this. + +3. **Wrap string labels in a nested element to avoid 20-char truncation.** `Breadcrumbs.renderBreadcrumbItem` slices any *string* label to 17 chars + `…`. Filenames and document titles hit this constantly. Pass a `ReactNode` instead and add CSS truncation: + + ```tsx + + + {fullName} + + + ``` + + The outer `span`'s `children` is now a node, not a string, so the JS truncation path is skipped. The inner `span` truncates only when actually too wide for the layout. The `title=` gives a hover tooltip with the full name. + +4. **`NavLink` works as a clickable breadcrumb item.** `GenericPageNavHeader` extracts `href` from the element and wires its own `onClick` to `navigate(href)`. No need to add `onClick` yourself. + +5. **No back button.** The breadcrumb itself is the way back. Don't add a separate `` — it duplicates the breadcrumb's affordance and clutters the header. + +#### Description prop + +The `description` prop renders an `Info` tooltip icon in the breadcrumb row. When there are **no** breadcrumbs the icon orphans above the title — looks broken. Either omit `description=`, or always pair it with at least one breadcrumb. + ## Data Fetching ### useFetch diff --git a/templates/plugin-template/.claude/skills/vertesia-ui/references/generic-table-pattern.md b/templates/plugin-template/.claude/skills/vertesia-ui/references/generic-table-pattern.md index 05552a7da..e3fe0d5d3 100644 --- a/templates/plugin-template/.claude/skills/vertesia-ui/references/generic-table-pattern.md +++ b/templates/plugin-template/.claude/skills/vertesia-ui/references/generic-table-pattern.md @@ -405,6 +405,10 @@ function dedupeFilters(filters: Filter[]) { ## Scroll Persistence Pattern +The naïve "save on scroll, restore on mount" approach has several traps that bit us in production. Apply the rules below — the helpers are simple, the gotchas are not. + +### Persist + read helpers + ```tsx function persistScrollTop(scrollTop: number) { const state = window.history.state || {}; @@ -423,7 +427,114 @@ function readScrollTop() { } ``` -Restore in `useLayoutEffect`, not plain `useEffect`. +### Find the actual scrolling element at runtime + +In nested layouts (e.g. an `AppLayout` with its own `overflow-y-auto` wrapper), the element that actually scrolls is **not always** the inner `ref` you attach to. The inner div may have `overflow-auto` but no overflow yet (during initial render with empty items), so the user ends up scrolling an ancestor. Walk up from the ref: + +```tsx +function findScrollableElement(start: HTMLElement | null): HTMLElement | null { + let current: HTMLElement | null = start; + while (current && current !== document.body) { + const overflowY = window.getComputedStyle(current).overflowY; + const canScroll = + (overflowY === 'auto' || overflowY === 'scroll') && + current.scrollHeight > current.clientHeight; + if (canScroll) return current; + current = current.parentElement; + } + return document.scrollingElement as HTMLElement | null; +} +``` + +Use the result for both attaching the listener AND for restoration. + +### Listener: persist on every event, **never on cleanup** + +```tsx +useEffect(() => { + const el = findScrollableElement(scrollContainerRef.current); + scrollElRef.current = el; + if (!el) return; + const onScroll = () => { + const next = el.scrollTop; + setScrollTop(next); // provider state (in-memory, survives reload) + persistScrollTop(next); // history.state (survives full reload) + }; + el.addEventListener('scroll', onScroll, { passive: true }); + return () => { + el.removeEventListener('scroll', onScroll); + // ⚠️ Do NOT read el.scrollTop here and "flush" it. By the time cleanup + // runs, the route has changed and the DOM is being detached — el.scrollTop + // reads as 0 and would clobber the value the on-scroll listener already + // saved. The listener has already persisted the latest value on every + // scroll; cleanup just removes the listener. + }; +}, [setScrollTop]); +``` + +### Restore: synchronous in `useLayoutEffect`, fall back to rAF only if needed + +A naïve `requestAnimationFrame(() => el.scrollTop = target)` produces a one-frame flash to the top before the rAF fires. Restore synchronously inside `useLayoutEffect` (which runs before paint); only poll rAF if the DOM hasn't laid out enough rows yet for the scroll target to be reachable. + +```tsx +useLayoutEffect(() => { + if (restoreDoneRef.current) return; + if (isLoading || items.length === 0) return; + const target = readScrollTop() ?? scrollTop; + if (target <= 0) { + restoreDoneRef.current = true; + return; + } + + const trySync = (): boolean => { + const el = scrollElRef.current ?? findScrollableElement(scrollContainerRef.current); + scrollElRef.current = el; + if (!el) return false; + const maxScroll = el.scrollHeight - el.clientHeight; + if (maxScroll < target) return false; + el.scrollTop = target; + return true; + }; + + if (trySync()) { + restoreDoneRef.current = true; + return; + } + + // Poll rAF until rows render enough scrollHeight for the target. + let attempts = 0, cancelled = false; + const tryAsync = () => { + if (cancelled) return; + if (trySync() || attempts >= 10) { + const el = scrollElRef.current; + if (el && !restoreDoneRef.current) { + const maxScroll = el.scrollHeight - el.clientHeight; + el.scrollTop = Math.min(target, Math.max(maxScroll, 0)); + } + restoreDoneRef.current = true; + return; + } + attempts++; + requestAnimationFrame(tryAsync); + }; + const frame = requestAnimationFrame(tryAsync); + return () => { cancelled = true; cancelAnimationFrame(frame); }; +}, [isLoading, items.length, scrollTop]); +``` + +### Provider state is the source of truth, history.state is a nice-to-have + +Push-based routers (including `NestedRouterProvider`) create a **new history entry** when you `navigate('/list')` from a detail page, so the original entry's saved `data.listScrollTop` is "behind" you and `readScrollTop()` returns `undefined`. The restore must fall back to provider state (`scrollTop`), which survives across route changes because the provider lives above the route boundary. The history.state path only helps for full-page reloads. + +### Common failure modes (what bit us) + +| Symptom | Cause | Fix | +| --- | --- | --- | +| Scroll resets to top on first back-nav, works on second | Cleanup persisted `el.scrollTop = 0` (DOM detached at cleanup time), clobbering the saved value | Don't persist on cleanup | +| Listener attaches to `` and never fires | `findScrollableElement` ran when items were empty; inner div had no overflow yet, so it walked all the way up | Walk-up logic + provider state covers it | +| Visible flash to top before scroll lands | Restore wrapped in `requestAnimationFrame` with no synchronous attempt | Try synchronously inside `useLayoutEffect` first | +| StrictMode double-mount loses scroll | Cleanup persists 0 between passes; second mount reads 0 | Don't persist on cleanup | +| `fromHistory = undefined` after back-nav | Router pushes new entry instead of going back via browser history | Provider state is the primary source | ## Checklist diff --git a/templates/plugin-template/src/ui/app/App.tsx b/templates/plugin-template/src/ui/app/App.tsx index be6d02a32..eb7c48637 100644 --- a/templates/plugin-template/src/ui/app/App.tsx +++ b/templates/plugin-template/src/ui/app/App.tsx @@ -1,8 +1,11 @@ import { NestedRouterProvider } from "@vertesia/ui/router"; +import { ContentObjectsListStateProvider } from "./features/content-objects"; import { routes } from "./routes"; export function App() { return ( - + + + ) } \ No newline at end of file diff --git a/templates/plugin-template/src/ui/app/features/content-objects/ContentObjectDetailView.tsx b/templates/plugin-template/src/ui/app/features/content-objects/ContentObjectDetailView.tsx new file mode 100644 index 000000000..e537109c4 --- /dev/null +++ b/templates/plugin-template/src/ui/app/features/content-objects/ContentObjectDetailView.tsx @@ -0,0 +1,62 @@ +import { ErrorBox, Spinner, useFetch } from '@vertesia/ui/core'; +import { ContentOverview, GenericPageNavHeader } from '@vertesia/ui/features'; +import { useUITranslation } from '@vertesia/ui/i18n'; +import { NavLink, useParams } from '@vertesia/ui/router'; +import { useUserSession } from '@vertesia/ui/session'; +import type { ContentObject } from '@vertesia/common'; + +export function ContentObjectDetailView() { + const { t } = useUITranslation(); + const { client } = useUserSession(); + const { id } = useParams() as { id?: string }; + + const { data: object, isLoading, error, refetch } = useFetch( + () => (id ? client.store.objects.retrieve(id) : Promise.resolve(undefined)), + [id], + ); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ {String(error)} +
+ ); + } + + if (!object) { + return ( +
+ {id ?? ''} +
+ ); + } + + return ( +
+ + {t('nav.objects')} + , + + + {object.name || object.id} + + , + ]} + /> +
+ +
+
+ ); +} diff --git a/templates/plugin-template/src/ui/app/features/content-objects/ContentObjectsListStateContext.ts b/templates/plugin-template/src/ui/app/features/content-objects/ContentObjectsListStateContext.ts new file mode 100644 index 000000000..14e9b5015 --- /dev/null +++ b/templates/plugin-template/src/ui/app/features/content-objects/ContentObjectsListStateContext.ts @@ -0,0 +1,48 @@ +import { + createContext, + useContext, + type Dispatch, + type MutableRefObject, + type SetStateAction, +} from 'react'; +import type { Filter } from '@vertesia/ui/core'; +import type { ContentObjectItem } from '@vertesia/common'; +import type { SortDir } from '../../components/SortableHead'; +import type { SortField } from './types'; + +export interface ContentObjectsListStateValue { + // Search params + query: string; + setQuery: Dispatch>; + filters: Filter[]; + setFilters: Dispatch>; + sortField: SortField; + setSortField: Dispatch>; + sortDir: SortDir; + setSortDir: Dispatch>; + + // Data + items: ContentObjectItem[]; + isLoading: boolean; + isLoadingMore: boolean; + hasMore: boolean; + loadMore: () => void; + refetch: () => Promise; + + // Scroll position (preserved across list/detail navigation) + scrollTopRef: MutableRefObject; +} + +export const ContentObjectsListStateContext = createContext< + ContentObjectsListStateValue | undefined +>(undefined); + +export function useContentObjectsListState() { + const ctx = useContext(ContentObjectsListStateContext); + if (!ctx) { + throw new Error( + 'useContentObjectsListState must be used inside ContentObjectsListStateProvider', + ); + } + return ctx; +} diff --git a/templates/plugin-template/src/ui/app/features/content-objects/ContentObjectsListStateProvider.tsx b/templates/plugin-template/src/ui/app/features/content-objects/ContentObjectsListStateProvider.tsx new file mode 100644 index 000000000..4d9eb2bf6 --- /dev/null +++ b/templates/plugin-template/src/ui/app/features/content-objects/ContentObjectsListStateProvider.tsx @@ -0,0 +1,185 @@ +import { + useCallback, + useMemo, + useRef, + useState, + type Dispatch, + type ReactNode, + type SetStateAction, +} from 'react'; +import { type Filter, useDebounce, useFetch, useToast } from '@vertesia/ui/core'; +import { useUITranslation } from '@vertesia/ui/i18n'; +import { useUserSession } from '@vertesia/ui/session'; +import type { ContentObjectItem } from '@vertesia/common'; +import type { SortDir } from '../../components/SortableHead'; +import { + ContentObjectsListStateContext, + type ContentObjectsListStateValue, +} from './ContentObjectsListStateContext'; +import { PAGE_SIZE, SORT_FIELD_MAP, type SortField } from './types'; +import { getSelectValues } from './utils'; + +// FilterProvider re-applies URL filters on mount. When the list state survives +// route changes, that re-application can append duplicate chips. Dedupe writes. +function dedupeFilters(filters: Filter[]) { + const seen = new Set(); + const deduped: Filter[] = []; + for (const filter of filters) { + const normalizedValue = Array.isArray(filter.value) + ? filter.value.map((entry) => + typeof entry === 'string' ? entry : `${entry.value}|${entry.label || ''}`, + ) + : []; + const key = [ + filter.name, + filter.type ?? '', + filter.multiple ? 'multi' : 'single', + ...normalizedValue, + ].join('::'); + if (seen.has(key)) continue; + seen.add(key); + deduped.push(filter); + } + return deduped; +} + +interface ProviderProps { + children: ReactNode; +} + +export function ContentObjectsListStateProvider({ children }: ProviderProps) { + const { t } = useUITranslation(); + const { client } = useUserSession(); + const toast = useToast(); + + const [query, setQuery] = useState(''); + const [filtersState, setFiltersState] = useState([]); + const [sortField, setSortField] = useState('updated'); + const [sortDir, setSortDir] = useState('desc'); + const [moreItems, setMoreItems] = useState([]); + const [hasMore, setHasMore] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const scrollTopRef = useRef(0); + + const debouncedQuery = useDebounce(query, 300); + + const setFilters: Dispatch> = useCallback((value) => { + setFiltersState((current) => { + const next = typeof value === 'function' ? value(current) : value; + return dedupeFilters(next); + }); + }, []); + + const buildQuery = useCallback(() => { + const typeIds = getSelectValues(filtersState, 'type'); + const statusValues = getSelectValues(filtersState, 'status'); + const trimmed = debouncedQuery.trim(); + return { + ...(trimmed ? { full_text: trimmed } : {}), + ...(typeIds.length ? { types: typeIds } : {}), + ...(statusValues.length ? { status: statusValues } : {}), + }; + }, [debouncedQuery, filtersState]); + + const sortPayload = useMemo( + () => [{ field: SORT_FIELD_MAP[sortField], order: sortDir }], + [sortField, sortDir], + ); + + const { data: firstPage, isLoading, refetch } = useFetch( + async () => { + const result = await client.objects.search({ + query: buildQuery(), + limit: PAGE_SIZE, + offset: 0, + sort: sortPayload, + }); + return result.results ?? []; + }, + { + deps: [debouncedQuery, filtersState, sortField, sortDir], + onSuccess: (results) => { + setMoreItems([]); + setHasMore(results.length >= PAGE_SIZE); + }, + onError: (err) => { + console.error('Content object search failed:', err); + toast({ status: 'error', title: t('objects.searchError') }); + }, + }, + ); + + const items = useMemo(() => [...(firstPage ?? []), ...moreItems], [firstPage, moreItems]); + + const loadMore = useCallback(() => { + if (isLoadingMore || !hasMore || isLoading) return; + setIsLoadingMore(true); + const offset = items.length; + client.objects + .search({ + query: buildQuery(), + limit: PAGE_SIZE, + offset, + sort: sortPayload, + }) + .then((result) => { + const results = result.results ?? []; + setMoreItems((prev) => [...prev, ...results]); + setHasMore(results.length >= PAGE_SIZE); + }) + .catch((err) => { + console.error('Load more failed:', err); + toast({ status: 'error', title: t('objects.searchError') }); + }) + .finally(() => setIsLoadingMore(false)); + }, [ + client, + buildQuery, + sortPayload, + items.length, + isLoadingMore, + isLoading, + hasMore, + toast, + t, + ]); + + const value: ContentObjectsListStateValue = useMemo( + () => ({ + query, + setQuery, + filters: filtersState, + setFilters, + sortField, + setSortField, + sortDir, + setSortDir, + items, + isLoading, + isLoadingMore, + hasMore, + loadMore, + refetch, + scrollTopRef, + }), + [ + query, + filtersState, + setFilters, + sortField, + sortDir, + items, + isLoading, + isLoadingMore, + hasMore, + loadMore, + refetch, + ], + ); + + return ( + + {children} + + ); +} diff --git a/templates/plugin-template/src/ui/app/features/content-objects/ContentObjectsView.tsx b/templates/plugin-template/src/ui/app/features/content-objects/ContentObjectsView.tsx index a3332b002..c07bdef93 100644 --- a/templates/plugin-template/src/ui/app/features/content-objects/ContentObjectsView.tsx +++ b/templates/plugin-template/src/ui/app/features/content-objects/ContentObjectsView.tsx @@ -1,8 +1,16 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + startTransition, + useCallback, + useDeferredValue, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; import { RefreshCw } from 'lucide-react'; import { Button, - type Filter, type FilterGroup, type FilterOption, FilterBar, @@ -14,34 +22,86 @@ import { TBody, THead, Table, - useDebounce, useIntersectionObserver, } from '@vertesia/ui/core'; import { GenericPageNavHeader } from '@vertesia/ui/features'; import { useUITranslation } from '@vertesia/ui/i18n'; +import { useNavigate } from '@vertesia/ui/router'; import { useUserSession } from '@vertesia/ui/session'; import type { ContentObjectTypeItem } from '@vertesia/common'; -import { SortableHead, type SortDir } from '../../components/SortableHead'; +import { SortableHead } from '../../components/SortableHead'; import { ContentObjectRow } from './components/ContentObjectRow'; -import { useContentObjectsSearch } from './hooks/useContentObjectsSearch'; +import { useContentObjectsListState } from './ContentObjectsListStateContext'; import { STATUS_VALUES, type FilterableField, type SortField } from './types'; +const SCROLL_HISTORY_KEY = 'contentObjectsScrollTop'; + +function persistScrollTop(scrollTop: number) { + const state = (window.history.state as { data?: Record } | null) ?? {}; + window.history.replaceState( + { + ...state, + data: { + ...((state.data as Record | undefined) ?? {}), + [SCROLL_HISTORY_KEY]: scrollTop, + }, + }, + '', + ); +} + +function readScrollTop(): number | undefined { + const state = window.history.state as + | { data?: Record } + | null; + const value = state?.data?.[SCROLL_HISTORY_KEY]; + return typeof value === 'number' ? value : undefined; +} + +// Walk up from `start` to find the nearest scrollable ancestor (the element that +// actually scrolls when the user scrolls inside it). Falls back to documentElement +// when no overflow:auto/scroll ancestor exists. +function findScrollableElement(start: HTMLElement | null): HTMLElement | null { + let current: HTMLElement | null = start; + while (current && current !== document.body) { + const overflowY = window.getComputedStyle(current).overflowY; + const canScroll = + (overflowY === 'auto' || overflowY === 'scroll') && + current.scrollHeight > current.clientHeight; + if (canScroll) return current; + current = current.parentElement; + } + return document.scrollingElement as HTMLElement | null; +} + export function ContentObjectsView() { const { t } = useUITranslation(); const { client } = useUserSession(); + const navigate = useNavigate(); - const [query, setQuery] = useState(''); - const [filters, setFilters] = useState([]); - const [sortField, setSortField] = useState('updated'); - const [sortDir, setSortDir] = useState('desc'); - const [types, setTypes] = useState([]); - - const debouncedQuery = useDebounce(query, 300); + const { + query, + setQuery, + filters, + setFilters, + sortField, + setSortField, + sortDir, + setSortDir, + items, + isLoading, + isLoadingMore, + loadMore, + refetch, + scrollTopRef, + } = useContentObjectsListState(); + const [types, setTypes] = useState([]); const loadMoreRef = useRef(null); - - const { items, isLoading, isLoadingMore, loadMore, refetch } = - useContentObjectsSearch({ debouncedQuery, filters, sortField, sortDir }); + const scrollContainerRef = useRef(null); + const scrollElRef = useRef(null); + const restoreDoneRef = useRef(false); + const deferredItems = useDeferredValue(items); useEffect(() => { client.store.types @@ -55,16 +115,111 @@ export function ContentObjectsView() { deps: [loadMore], }); - const handleSort = useCallback( - (field: SortField) => { - if (sortField === field) { - setSortDir((current) => (current === 'asc' ? 'desc' : 'asc')); + // Persist scroll on every event. The actual scrolling element may be an + // ancestor of `scrollContainerRef` (e.g. AppLayout's outer overflow-y-auto), + // so find it dynamically. + // + // Important: do NOT persist on cleanup. By the time cleanup runs, the route + // has changed and the View's DOM is being detached — `el.scrollTop` reads as + // 0 and would overwrite the saved value. The on-scroll listener already + // persists every value during the user's scroll, so the latest position is + // already saved when navigation happens. + useEffect(() => { + const el = findScrollableElement(scrollContainerRef.current); + scrollElRef.current = el; + if (!el) return; + const onScroll = () => { + const next = el.scrollTop; + scrollTopRef.current = next; + persistScrollTop(next); + }; + el.addEventListener('scroll', onScroll, { passive: true }); + return () => { + el.removeEventListener('scroll', onScroll); + }; + }, [scrollTopRef]); + + // Restore scroll once the list has rendered. useLayoutEffect (not useEffect) + // so the scroll happens before paint. A single rAF is not enough — the table + // can take a few frames to lay out enough rows that scrollHeight reaches the + // target. Poll up to maxAttempts frames waiting for sufficient scrollHeight. + // Restore scroll once the list has rendered. useLayoutEffect runs after DOM + // mutations but BEFORE paint — so doing the scroll synchronously here means + // the user never sees the un-restored top-of-list flash. We only fall back + // to rAF polling if scrollHeight isn't yet tall enough (rare — happens with + // virtualized lists or async row mounting). + useLayoutEffect(() => { + if (restoreDoneRef.current) return; + if (isLoading || items.length === 0) return; + const target = readScrollTop() ?? scrollTopRef.current; + if (target <= 0) { + restoreDoneRef.current = true; + return; + } + + const trySync = (): boolean => { + const el = + scrollElRef.current ?? + findScrollableElement(scrollContainerRef.current); + scrollElRef.current = el; + if (!el) return false; + const maxScroll = el.scrollHeight - el.clientHeight; + if (maxScroll < target) return false; + el.scrollTop = target; + return true; + }; + + if (trySync()) { + restoreDoneRef.current = true; + return; + } + + // scrollHeight too small right now — poll rAF until rows fill it out. + let attempts = 0; + const maxAttempts = 10; + let cancelled = false; + const tryAsync = () => { + if (cancelled) return; + if (trySync() || attempts >= maxAttempts) { + const el = scrollElRef.current; + if (el && !restoreDoneRef.current) { + const maxScroll = el.scrollHeight - el.clientHeight; + el.scrollTop = Math.min(target, Math.max(maxScroll, 0)); + } + restoreDoneRef.current = true; return; } - setSortField(field); - setSortDir('asc'); + attempts++; + requestAnimationFrame(tryAsync); + }; + const frame = requestAnimationFrame(tryAsync); + return () => { + cancelled = true; + cancelAnimationFrame(frame); + }; + }, [isLoading, items.length, scrollTopRef]); + + const handleSort = useCallback( + (field: SortField) => { + startTransition(() => { + if (sortField === field) { + setSortDir((current) => (current === 'asc' ? 'desc' : 'asc')); + return; + } + setSortField(field); + setSortDir('asc'); + }); + }, + [sortField, setSortField, setSortDir], + ); + + const handleRowOpen = useCallback( + (id: string) => { + startTransition(() => { + navigate(`/objects/${id}`); + }); }, - [sortField], + [navigate], ); const addFilterValue = useCallback( @@ -72,33 +227,35 @@ export function ContentObjectsView() { const placeholder = name === 'type' ? t('objects.filterType') : t('objects.filterStatus'); const newOption: FilterOption = { value, label }; - setFilters((prev) => { - const existing = prev.find((f) => f.name === name); - if (!existing) { - return [ - ...prev, - { - name, - placeholder, - type: 'select', - multiple: true, - value: [newOption], - }, - ]; - } - const currentValues = Array.isArray(existing.value) ? existing.value : []; - const alreadyHas = currentValues.some( - (v) => (typeof v === 'string' ? v : v.value) === value, - ); - if (alreadyHas) return prev; - return prev.map((f) => - f === existing - ? { ...f, value: [...(f.value as FilterOption[]), newOption] } - : f, - ); + startTransition(() => { + setFilters((prev) => { + const existing = prev.find((f) => f.name === name); + if (!existing) { + return [ + ...prev, + { + name, + placeholder, + type: 'select', + multiple: true, + value: [newOption], + }, + ]; + } + const currentValues = Array.isArray(existing.value) ? existing.value : []; + const alreadyHas = currentValues.some( + (v) => (typeof v === 'string' ? v : v.value) === value, + ); + if (alreadyHas) return prev; + return prev.map((f) => + f === existing + ? { ...f, value: [...(f.value as FilterOption[]), newOption] } + : f, + ); + }); }); }, - [t], + [t, setFilters], ); const filterGroups: FilterGroup[] = useMemo( @@ -126,11 +283,25 @@ export function ContentObjectsView() { [types, t], ); - const showEmpty = !isLoading && items.length === 0; + const tableRows = useMemo( + () => + deferredItems.map((item) => ( + + )), + [deferredItems, t, addFilterValue, handleRowOpen], + ); + + const showEmpty = !isLoading && deferredItems.length === 0; return (
- +
-
+
@@ -196,14 +367,7 @@ export function ContentObjectsView() { - {items.map((item) => ( - - ))} + {tableRows}
{isLoadingMore && ( diff --git a/templates/plugin-template/src/ui/app/features/content-objects/components/ContentObjectRow.tsx b/templates/plugin-template/src/ui/app/features/content-objects/components/ContentObjectRow.tsx index 665ebb832..18a02d4fb 100644 --- a/templates/plugin-template/src/ui/app/features/content-objects/components/ContentObjectRow.tsx +++ b/templates/plugin-template/src/ui/app/features/content-objects/components/ContentObjectRow.tsx @@ -1,3 +1,4 @@ +import { memo, useMemo } from 'react'; import { Badge } from '@vertesia/ui/core'; import type { ContentObjectItem } from '@vertesia/common'; import { InlineFilterButton } from '../../../components/InlineFilterButton'; @@ -8,16 +9,23 @@ interface ContentObjectRowProps { item: ContentObjectItem; t: (key: string, opts?: Record) => string; onAddFilter: (name: FilterableField, value: string, label: string) => void; + onOpen: (id: string) => void; } -export function ContentObjectRow({ item, t, onAddFilter }: ContentObjectRowProps) { - const updated = item.updated_at ? new Date(item.updated_at).toLocaleString() : '—'; +function ContentObjectRowImpl({ item, t, onAddFilter, onOpen }: ContentObjectRowProps) { + const updated = useMemo( + () => (item.updated_at ? new Date(item.updated_at).toLocaleString() : '—'), + [item.updated_at], + ); const typeName = item.type?.name; const typeId = item.type && 'id' in item.type ? item.type.id : undefined; const statusLabel = item.status ? t(`objects.status.${item.status}`) : '—'; return ( - + onOpen(item.id)} + >
{item.name || item.id} @@ -56,3 +64,5 @@ export function ContentObjectRow({ item, t, onAddFilter }: ContentObjectRowProps ); } + +export const ContentObjectRow = memo(ContentObjectRowImpl); diff --git a/templates/plugin-template/src/ui/app/features/content-objects/hooks/useContentObjectsSearch.ts b/templates/plugin-template/src/ui/app/features/content-objects/hooks/useContentObjectsSearch.ts deleted file mode 100644 index ba6bd0b0e..000000000 --- a/templates/plugin-template/src/ui/app/features/content-objects/hooks/useContentObjectsSearch.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { useCallback, useMemo, useState } from 'react'; -import { useFetch, useToast, type Filter } from '@vertesia/ui/core'; -import { useUITranslation } from '@vertesia/ui/i18n'; -import { useUserSession } from '@vertesia/ui/session'; -import type { ContentObjectItem } from '@vertesia/common'; -import type { SortDir } from '../../../components/SortableHead'; -import { PAGE_SIZE, SORT_FIELD_MAP, type SortField } from '../types'; -import { getSelectValues } from '../utils'; - -interface UseContentObjectsSearchArgs { - debouncedQuery: string; - filters: Filter[]; - sortField: SortField; - sortDir: SortDir; -} - -export function useContentObjectsSearch({ - debouncedQuery, - filters, - sortField, - sortDir, -}: UseContentObjectsSearchArgs) { - const { t } = useUITranslation(); - const { client } = useUserSession(); - const toast = useToast(); - - const [moreItems, setMoreItems] = useState([]); - const [hasMore, setHasMore] = useState(false); - const [isLoadingMore, setIsLoadingMore] = useState(false); - - const buildQuery = useCallback(() => { - const typeIds = getSelectValues(filters, 'type'); - const statusValues = getSelectValues(filters, 'status'); - const trimmed = debouncedQuery.trim(); - return { - ...(trimmed ? { full_text: trimmed } : {}), - ...(typeIds.length ? { types: typeIds } : {}), - ...(statusValues.length ? { status: statusValues } : {}), - }; - }, [debouncedQuery, filters]); - - const sortPayload = useMemo( - () => [{ field: SORT_FIELD_MAP[sortField], order: sortDir }], - [sortField, sortDir], - ); - - const { data: firstPage, isLoading, refetch } = useFetch( - async () => { - const result = await client.objects.search({ - query: buildQuery(), - limit: PAGE_SIZE, - offset: 0, - sort: sortPayload, - }); - return result.results ?? []; - }, - { - deps: [debouncedQuery, filters, sortField, sortDir], - onSuccess: (results) => { - setMoreItems([]); - setHasMore(results.length >= PAGE_SIZE); - }, - onError: (err) => { - console.error('Content object search failed:', err); - toast({ status: 'error', title: t('objects.searchError') }); - }, - }, - ); - - const items = useMemo(() => [...(firstPage ?? []), ...moreItems], [firstPage, moreItems]); - - const loadMore = useCallback(() => { - if (isLoadingMore || !hasMore || isLoading) return; - setIsLoadingMore(true); - const offset = items.length; - client.objects - .search({ - query: buildQuery(), - limit: PAGE_SIZE, - offset, - sort: sortPayload, - }) - .then((result) => { - const results = result.results ?? []; - setMoreItems((prev) => [...prev, ...results]); - setHasMore(results.length >= PAGE_SIZE); - }) - .catch((err) => { - console.error('Load more failed:', err); - toast({ status: 'error', title: t('objects.searchError') }); - }) - .finally(() => setIsLoadingMore(false)); - }, [ - client, - buildQuery, - sortPayload, - items.length, - isLoadingMore, - isLoading, - hasMore, - toast, - t, - ]); - - return { - items, - isLoading, - isLoadingMore, - hasMore, - loadMore, - refetch, - }; -} diff --git a/templates/plugin-template/src/ui/app/features/content-objects/index.ts b/templates/plugin-template/src/ui/app/features/content-objects/index.ts index 61ae38626..4fd554616 100644 --- a/templates/plugin-template/src/ui/app/features/content-objects/index.ts +++ b/templates/plugin-template/src/ui/app/features/content-objects/index.ts @@ -1,2 +1,5 @@ export { ContentObjectsView } from './ContentObjectsView'; +export { ContentObjectDetailView } from './ContentObjectDetailView'; +export { ContentObjectsListStateProvider } from './ContentObjectsListStateProvider'; +export { useContentObjectsListState } from './ContentObjectsListStateContext'; export type { SortField, FilterableField } from './types'; diff --git a/templates/plugin-template/src/ui/app/pages/ContentObjectDetailPage.tsx b/templates/plugin-template/src/ui/app/pages/ContentObjectDetailPage.tsx new file mode 100644 index 000000000..a9f9317c8 --- /dev/null +++ b/templates/plugin-template/src/ui/app/pages/ContentObjectDetailPage.tsx @@ -0,0 +1,5 @@ +import { ContentObjectDetailView } from '../features/content-objects'; + +export function ContentObjectDetailPage() { + return ; +} diff --git a/templates/plugin-template/src/ui/app/routes.tsx b/templates/plugin-template/src/ui/app/routes.tsx index 91f1c4344..553fb8df4 100644 --- a/templates/plugin-template/src/ui/app/routes.tsx +++ b/templates/plugin-template/src/ui/app/routes.tsx @@ -1,4 +1,5 @@ import { ChatPage } from "./pages/ChatPage"; +import { ContentObjectDetailPage } from "./pages/ContentObjectDetailPage"; import { ContentObjectsPage } from "./pages/ContentObjectsPage"; import { HomePage } from "./pages/HomePage"; @@ -11,6 +12,10 @@ export const routes = [ path: '/objects', Component: () => , }, + { + path: '/objects/:id', + Component: () => , + }, { path: '/chat', Component: () => , diff --git a/templates/plugin-template/src/ui/i18n/locales/en.json b/templates/plugin-template/src/ui/i18n/locales/en.json index 99b9a1e45..00b4d5ab8 100644 --- a/templates/plugin-template/src/ui/i18n/locales/en.json +++ b/templates/plugin-template/src/ui/i18n/locales/en.json @@ -21,6 +21,8 @@ "objects.col.status": "Status", "objects.col.type": "Type", "objects.col.updated": "Updated", + "objects.detail.loadError": "Failed to load this content object.", + "objects.detail.notFound": "Content object not found.", "objects.empty": "No content objects found.", "objects.filterByValue": "Filter by {{value}}", "objects.filterStatus": "Status", diff --git a/templates/plugin-template/src/ui/i18n/locales/fr.json b/templates/plugin-template/src/ui/i18n/locales/fr.json index 32dcbc189..e1a91972d 100644 --- a/templates/plugin-template/src/ui/i18n/locales/fr.json +++ b/templates/plugin-template/src/ui/i18n/locales/fr.json @@ -21,6 +21,8 @@ "objects.col.status": "Statut", "objects.col.type": "Type", "objects.col.updated": "Mis à jour", + "objects.detail.loadError": "Échec du chargement de cet objet de contenu.", + "objects.detail.notFound": "Objet de contenu introuvable.", "objects.empty": "Aucun objet de contenu trouvé.", "objects.filterByValue": "Filtrer par {{value}}", "objects.filterStatus": "Statut", From d599ef8c537eb81bd2234bb2240dd20c6465fe6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Vachette?= <5880528+michaelva@users.noreply.github.com> Date: Fri, 8 May 2026 14:34:54 +0900 Subject: [PATCH 63/75] fix eslint --- templates/plugin-template/eslint.config.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/templates/plugin-template/eslint.config.js b/templates/plugin-template/eslint.config.js index ae5c519fb..d451e0669 100644 --- a/templates/plugin-template/eslint.config.js +++ b/templates/plugin-template/eslint.config.js @@ -37,11 +37,6 @@ export default [ 'warn', { allowConstantExport: true }, ], - '@typescript-eslint/no-explicit-any': 'warn', - '@typescript-eslint/no-unused-vars': ['warn', { - argsIgnorePattern: '^_', - varsIgnorePattern: '^_', - }], '@typescript-eslint/no-unused-vars': [ 'error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, From 8730903ebbc8808238948e8b1d7187c6916c2124 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Vachette?= <5880528+michaelva@users.noreply.github.com> Date: Fri, 8 May 2026 14:35:05 +0900 Subject: [PATCH 64/75] reusable preview panels --- packages/ui/src/features/index.ts | 1 + .../src/features/media-viewer/AudioPanel.tsx | 126 ++++++++ .../src/features/media-viewer/ImagePanel.tsx | 102 +++++++ .../src/features/media-viewer/VideoPanel.tsx | 131 ++++++++ .../ui/src/features/media-viewer/formats.ts | 38 +++ .../ui/src/features/media-viewer/index.ts | 4 + .../objects/components/ContentOverview.tsx | 280 +----------------- 7 files changed, 404 insertions(+), 278 deletions(-) create mode 100644 packages/ui/src/features/media-viewer/AudioPanel.tsx create mode 100644 packages/ui/src/features/media-viewer/ImagePanel.tsx create mode 100644 packages/ui/src/features/media-viewer/VideoPanel.tsx create mode 100644 packages/ui/src/features/media-viewer/formats.ts create mode 100644 packages/ui/src/features/media-viewer/index.ts diff --git a/packages/ui/src/features/index.ts b/packages/ui/src/features/index.ts index bd76d4fe8..d9d6deb5c 100644 --- a/packages/ui/src/features/index.ts +++ b/packages/ui/src/features/index.ts @@ -4,6 +4,7 @@ export * from "./errors/index.js"; export * from "./facets/index.js"; export * from "./layout/index.js"; export * from "./magic-pdf/index.js"; +export * from "./media-viewer/index.js"; export * from "./pdf-viewer/index.js"; export * from "./permissions/index.js"; export * from "./store/index.js"; diff --git a/packages/ui/src/features/media-viewer/AudioPanel.tsx b/packages/ui/src/features/media-viewer/AudioPanel.tsx new file mode 100644 index 000000000..d1b857873 --- /dev/null +++ b/packages/ui/src/features/media-viewer/AudioPanel.tsx @@ -0,0 +1,126 @@ +import { AUDIO_RENDITION_NAME, AudioMetadata, ContentNature, ContentObject } from "@vertesia/common"; +import { Spinner } from "@vertesia/ui/core"; +import { useUserSession } from "@vertesia/ui/session"; +import { useEffect, useState } from "react"; +import { useUITranslation } from '../../i18n/index.js'; +import { formatDuration, WEB_SUPPORTED_AUDIO_FORMATS } from "./formats.js"; + +interface AudioPanelProps { + /** Direct signed URL — used as-is, no resolution. */ + url?: string; + /** Storage path; resolved via `client.files.getDownloadUrl`. */ + source?: string; + /** ContentObject — uses the audio rendition or falls back to the original if web-supported. */ + object?: ContentObject; + /** Extra classes for the wrapper. */ + className?: string; +} + +/** + * Renders an audio player from a direct URL, a storage source path, or a Vertesia ContentObject. + * Resolution priority: `url` > `source` > `object`. Duration is shown only in object mode. + */ +export function AudioPanel({ url, source, object, className }: AudioPanelProps) { + const { t } = useUITranslation(); + const { client } = useUserSession(); + const [audioUrl, setAudioUrl] = useState(url); + const [isLoading, setIsLoading] = useState(!url && (!!source || !!object)); + + const metadata = object?.metadata as AudioMetadata | undefined; + const renditions = metadata?.renditions || []; + const audioRendition = renditions.find(r => r.name === AUDIO_RENDITION_NAME); + const isOriginalWebSupported = object?.content?.type + && WEB_SUPPORTED_AUDIO_FORMATS.includes(object.content.type); + const showsObjectFallbackEmpty = !!object + && object.metadata?.type === ContentNature.Audio + && !audioRendition + && !isOriginalWebSupported; + + useEffect(() => { + if (url) { + setAudioUrl(url); + setIsLoading(false); + return; + } + + setAudioUrl(undefined); + + const load = async () => { + try { + if (source) { + const downloadUrl = await client.files.getDownloadUrl(source); + setAudioUrl(downloadUrl.url); + return; + } + + if (!object) return; + if (object.metadata?.type !== ContentNature.Audio) return; + + let downloadUrl; + if (audioRendition?.content?.source) { + downloadUrl = await client.files.getDownloadUrl(audioRendition.content.source); + } else if (isOriginalWebSupported && object.content?.source) { + downloadUrl = await client.files.getDownloadUrl(object.content.source); + } + if (downloadUrl) { + setAudioUrl(downloadUrl.url); + } + } catch (error) { + console.error("Failed to get audio URL", error); + } finally { + setIsLoading(false); + } + }; + + if (source || object) { + setIsLoading(true); + load(); + } else { + setIsLoading(false); + } + }, [url, source, object?.id, object?.content?.type, object?.content?.source, object?.metadata, audioRendition, isOriginalWebSupported, client]); + + if (showsObjectFallbackEmpty) { + return ( +
+
+

{t('store.noAudioRendition')}

+

{t('store.audioFormatRequired')}

+
+
+ ); + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!audioUrl) { + return ( +
+ Failed to load audio +
+ ); + } + + return ( +
+ + {metadata?.duration && ( +
+ Duration: {formatDuration(metadata.duration)} +
+ )} +
+ ); +} diff --git a/packages/ui/src/features/media-viewer/ImagePanel.tsx b/packages/ui/src/features/media-viewer/ImagePanel.tsx new file mode 100644 index 000000000..bb10c6492 --- /dev/null +++ b/packages/ui/src/features/media-viewer/ImagePanel.tsx @@ -0,0 +1,102 @@ +import { ContentNature, ContentObject, ImageRenditionFormat } from "@vertesia/common"; +import { Spinner } from "@vertesia/ui/core"; +import { useUserSession } from "@vertesia/ui/session"; +import { useEffect, useState } from "react"; +import { WEB_SUPPORTED_IMAGE_FORMATS } from "./formats.js"; + +interface ImagePanelProps { + /** Direct signed URL — used as-is, no resolution. */ + url?: string; + /** Storage path; resolved via `client.files.getDownloadUrl`. */ + source?: string; + /** ContentObject — tries the JPEG rendition first, falls back to the original if web-supported. */ + object?: ContentObject; + /** Extra classes for the wrapper. */ + className?: string; +} + +/** + * Renders an image from a direct URL, a storage source path, or a Vertesia ContentObject. + * Resolution priority: `url` > `source` > `object`. + */ +export function ImagePanel({ url, source, object, className }: ImagePanelProps) { + const { client } = useUserSession(); + const [imageUrl, setImageUrl] = useState(url); + const [isLoading, setIsLoading] = useState(!url && (!!source || !!object)); + + useEffect(() => { + if (url) { + setImageUrl(url); + setIsLoading(false); + return; + } + + setImageUrl(undefined); + + const load = async () => { + try { + if (source) { + const downloadUrl = await client.files.getDownloadUrl(source); + setImageUrl(downloadUrl.url); + return; + } + + if (!object) return; + + const isImage = object.metadata?.type === ContentNature.Image; + if (!isImage) return; + + const isOriginalWebSupported = object.content?.type + && WEB_SUPPORTED_IMAGE_FORMATS.includes(object.content.type); + + try { + const rendition = await client.objects.getRendition(object.id, { + format: ImageRenditionFormat.jpeg, + generate_if_missing: false, + sign_url: true, + }); + if (rendition.status === "found" && rendition.renditions?.length) { + setImageUrl(rendition.renditions[0]); + return; + } + } catch { + // fall through to original + } + + if (isOriginalWebSupported && object.content?.source) { + const downloadUrl = await client.files.getDownloadUrl(object.content.source); + setImageUrl(downloadUrl.url); + } + } finally { + setIsLoading(false); + } + }; + + if (source || object) { + setIsLoading(true); + load(); + } else { + setIsLoading(false); + } + }, [url, source, object?.id, object?.content?.type, object?.content?.source, object?.metadata, client]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!imageUrl) { + return null; + } + + return ( + {object?.name} + ); +} diff --git a/packages/ui/src/features/media-viewer/VideoPanel.tsx b/packages/ui/src/features/media-viewer/VideoPanel.tsx new file mode 100644 index 000000000..51a16f605 --- /dev/null +++ b/packages/ui/src/features/media-viewer/VideoPanel.tsx @@ -0,0 +1,131 @@ +import { ContentNature, ContentObject, POSTER_RENDITION_NAME, VideoMetadata } from "@vertesia/common"; +import { Spinner } from "@vertesia/ui/core"; +import { useUserSession } from "@vertesia/ui/session"; +import { useEffect, useState } from "react"; +import { useUITranslation } from '../../i18n/index.js'; +import { WEB_SUPPORTED_VIDEO_FORMATS } from "./formats.js"; + +interface VideoPanelProps { + /** Direct signed URL — used as-is, no resolution. */ + url?: string; + /** Storage path; resolved via `client.files.getDownloadUrl`. */ + source?: string; + /** ContentObject — picks an mp4/webm rendition or falls back to the original if web-supported. */ + object?: ContentObject; + /** Extra classes for the wrapper. */ + className?: string; +} + +/** + * Renders a video from a direct URL, a storage source path, or a Vertesia ContentObject. + * Resolution priority: `url` > `source` > `object`. Poster image only resolves in object mode. + */ +export function VideoPanel({ url, source, object, className }: VideoPanelProps) { + const { t } = useUITranslation(); + const { client } = useUserSession(); + const [videoUrl, setVideoUrl] = useState(url); + const [posterUrl, setPosterUrl] = useState(); + const [isLoading, setIsLoading] = useState(!url && (!!source || !!object)); + + const metadata = object?.metadata as VideoMetadata | undefined; + const renditions = metadata?.renditions || []; + const webRendition = renditions.find(r => r.content.type === 'video/mp4') + || renditions.find(r => r.content.type === 'video/webm'); + const isOriginalWebSupported = object?.content?.type + && WEB_SUPPORTED_VIDEO_FORMATS.includes(object.content.type); + const poster = renditions.find(r => r.name === POSTER_RENDITION_NAME); + const showsObjectFallbackEmpty = !!object + && object.metadata?.type === ContentNature.Video + && !webRendition + && !isOriginalWebSupported; + + useEffect(() => { + if (url) { + setVideoUrl(url); + setIsLoading(false); + return; + } + + setVideoUrl(undefined); + setPosterUrl(undefined); + + const load = async () => { + try { + if (source) { + const downloadUrl = await client.files.getDownloadUrl(source); + setVideoUrl(downloadUrl.url); + return; + } + + if (!object) return; + if (object.metadata?.type !== ContentNature.Video) return; + + let downloadUrl; + if (webRendition?.content?.source) { + downloadUrl = await client.files.getDownloadUrl(webRendition.content.source); + } else if (isOriginalWebSupported && object.content?.source) { + downloadUrl = await client.files.getDownloadUrl(object.content.source); + } + if (downloadUrl) { + setVideoUrl(downloadUrl.url); + } + } catch (error) { + console.error("Failed to get video URL", error); + } finally { + setIsLoading(false); + } + }; + + if (source || object) { + setIsLoading(true); + load(); + } else { + setIsLoading(false); + } + }, [url, source, object?.id, object?.content?.type, object?.content?.source, object?.metadata, webRendition, isOriginalWebSupported, client]); + + useEffect(() => { + if (!poster?.content?.source) return; + client.files.getDownloadUrl(poster.content.source) + .then((response) => setPosterUrl(response.url)) + .catch((error) => console.error("Failed to load poster image", error)); + }, [poster, client]); + + if (showsObjectFallbackEmpty) { + return ( +
+
+

{t('store.noVideoRendition')}

+

{t('store.videoFormatRequired')}

+
+
+ ); + } + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!videoUrl) { + return ( +
+ Failed to load video +
+ ); + } + + return ( + + ); +} diff --git a/packages/ui/src/features/media-viewer/formats.ts b/packages/ui/src/features/media-viewer/formats.ts new file mode 100644 index 000000000..a7550f0e0 --- /dev/null +++ b/packages/ui/src/features/media-viewer/formats.ts @@ -0,0 +1,38 @@ +/** + * Mime types the browser can render natively in ,
+ ); +} diff --git a/templates/plugin-template/src/ui/app/features/conversations/components/ConversationRow.tsx b/templates/plugin-template/src/ui/app/features/conversations/components/ConversationRow.tsx new file mode 100644 index 000000000..218a4e714 --- /dev/null +++ b/templates/plugin-template/src/ui/app/features/conversations/components/ConversationRow.tsx @@ -0,0 +1,60 @@ +import { Badge } from '@vertesia/ui/core'; +import type { AgentRunSearchHit } from '@vertesia/common'; +import { InlineFilterButton } from '../../../components/InlineFilterButton'; +import type { FilterableField } from '../types'; +import { statusVariant } from '../utils'; + +interface ConversationRowProps { + hit: AgentRunSearchHit; + t: (key: string, opts?: Record) => string; + onAddFilter: (name: FilterableField, value: string, label: string) => void; + onOpen: (id: string) => void; +} + +export function ConversationRow({ hit, t, onAddFilter, onOpen }: ConversationRowProps) { + const topic = hit.topic || hit.title || t('conversations.untitled'); + const interaction = hit.interaction; + const interactionLabel = hit.interaction_name || interaction; + const started = hit.started_at ? new Date(hit.started_at).toLocaleString() : '—'; + const status = hit.status; + + return ( + onOpen(hit.id)} + > + +
+ {topic} +
+ + +
+ {interactionLabel ?? '—'} + {interaction && ( + + onAddFilter('agent', interaction, interactionLabel ?? interaction) + } + /> + )} +
+ + +
+ {status ?? '—'} + {status && ( + onAddFilter('status', status, status)} + /> + )} +
+ + {started} + + ); +} diff --git a/templates/plugin-template/src/ui/app/features/conversations/index.ts b/templates/plugin-template/src/ui/app/features/conversations/index.ts new file mode 100644 index 000000000..9ff638c71 --- /dev/null +++ b/templates/plugin-template/src/ui/app/features/conversations/index.ts @@ -0,0 +1,3 @@ +export { ConversationsView } from './ConversationsView'; +export { ConversationsListStateProvider } from './ConversationsListStateProvider'; +export { useConversationsListState } from './ConversationsListStateContext'; diff --git a/templates/plugin-template/src/ui/app/features/conversations/types.ts b/templates/plugin-template/src/ui/app/features/conversations/types.ts new file mode 100644 index 000000000..3fd35a01a --- /dev/null +++ b/templates/plugin-template/src/ui/app/features/conversations/types.ts @@ -0,0 +1,31 @@ +import type { AgentRunStatus } from '@vertesia/common'; + +export type SortField = 'topic' | 'agent' | 'status' | 'started'; + +export const SORT_FIELD_MAP: Record = { + topic: 'topic', + agent: 'interaction', + status: 'status', + started: 'started_at', +}; + +export const PAGE_SIZE = 100; + +export const STATUS_VALUES: AgentRunStatus[] = [ + 'created', + 'running', + 'completed', + 'failed', + 'cancelled', +]; + +export type FilterableField = 'status' | 'agent'; + +export type BadgeVariant = + | 'default' + | 'secondary' + | 'destructive' + | 'attention' + | 'success' + | 'info' + | 'done'; diff --git a/templates/plugin-template/src/ui/app/features/conversations/utils.ts b/templates/plugin-template/src/ui/app/features/conversations/utils.ts new file mode 100644 index 000000000..cd15ae071 --- /dev/null +++ b/templates/plugin-template/src/ui/app/features/conversations/utils.ts @@ -0,0 +1,25 @@ +import type { Filter } from '@vertesia/ui/core'; +import type { AgentRunStatus } from '@vertesia/common'; +import type { BadgeVariant } from './types'; + +export function statusVariant(status?: AgentRunStatus): BadgeVariant { + switch (status) { + case 'completed': + return 'success'; + case 'failed': + case 'cancelled': + return 'destructive'; + case 'running': + return 'attention'; + default: + return 'default'; + } +} + +export function getSelectValues(filters: Filter[], name: string): string[] { + const filter = filters.find((f) => f.name === name); + if (!filter || !Array.isArray(filter.value) || filter.value.length === 0) return []; + return filter.value + .map((v) => (typeof v === 'string' ? v : v.value ?? '')) + .filter((v): v is string => Boolean(v)); +} diff --git a/templates/plugin-template/src/ui/app/layouts/PluginSidebar.tsx b/templates/plugin-template/src/ui/app/layouts/PluginSidebar.tsx index 9a3c87e2e..0ceaa7b3d 100644 --- a/templates/plugin-template/src/ui/app/layouts/PluginSidebar.tsx +++ b/templates/plugin-template/src/ui/app/layouts/PluginSidebar.tsx @@ -1,14 +1,16 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import { ModeToggle } from '@vertesia/ui/core'; import { useUITranslation } from '@vertesia/ui/i18n'; import { SidebarSection, useSidebarToggle } from '@vertesia/ui/layout'; import { useLocation, useRouterBasePath } from '@vertesia/ui/router'; import { useUserSession } from '@vertesia/ui/session'; -import { Database, HomeIcon, MessageSquare, PlusCircle } from 'lucide-react'; +import { Database, HomeIcon, MessageSquare, MessagesSquare, PlusCircle } from 'lucide-react'; import type { AgentRunResponse, WorkflowRun } from '@vertesia/common'; import { AppSidebarItem } from './AppSidebarItem'; import { ASSISTANT_INTERACTION } from '../constants'; +const SIDEBAR_RECENT_LIMIT = 3; + function toWorkflowRun(run: AgentRunResponse): WorkflowRun { const isAgentRun = run.run_kind === 'agent'; @@ -29,49 +31,14 @@ function toWorkflowRun(run: AgentRunResponse): WorkflowRun { function getConversationLabel(conv: WorkflowRun, t: (key: string) => string): string { if (conv.topic) return conv.topic; - // input is not populated by listConversations, but check anyway for forward compat const prompt = conv.input?.data?.user_prompt; if (typeof prompt === 'string' && prompt.trim()) return prompt.trim(); - // Fall back to a formatted date/time if (conv.started_at) { return new Date(conv.started_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } return t('nav.conversation'); } -function getDateLabel(date: Date, t: (key: string) => string): string { - const now = new Date(); - const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - const yesterday = new Date(today); - yesterday.setDate(yesterday.getDate() - 1); - const target = new Date(date.getFullYear(), date.getMonth(), date.getDate()); - - if (target.getTime() === today.getTime()) return t('nav.today'); - if (target.getTime() === yesterday.getTime()) return t('nav.yesterday'); - return date.toLocaleDateString(); -} - -interface GroupedConversations { - dateLabel: string; - conversations: WorkflowRun[]; -} - -function groupByDate(conversations: WorkflowRun[], t: (key: string) => string): GroupedConversations[] { - const groups: GroupedConversations[] = []; - let currentLabel = ''; - for (const conv of conversations) { - const date = conv.started_at ? new Date(conv.started_at) : new Date(); - const label = getDateLabel(date, t); - if (label !== currentLabel) { - currentLabel = label; - groups.push({ dateLabel: label, conversations: [conv] }); - } else { - groups[groups.length - 1].conversations.push(conv); - } - } - return groups; -} - export function PluginSidebar() { const { t } = useUITranslation(); const path = useLocation().pathname; @@ -83,14 +50,12 @@ export function PluginSidebar() { useEffect(() => { client.agents.list({ interaction: ASSISTANT_INTERACTION, - limit: 20, + limit: SIDEBAR_RECENT_LIMIT, sort: 'started_at', order: 'desc', }).then(response => setConversations(response.items.map(toWorkflowRun))); }, [client]); - const grouped = useMemo(() => groupByDate(conversations, t), [conversations, t]); - return (
@@ -112,6 +77,14 @@ export function PluginSidebar() { > {t('nav.objects')} + + {t('nav.conversations')} + - {grouped.map(group => ( - - {group.conversations.map(conv => { + {conversations.length > 0 && ( + + {conversations.map(conv => { const convPath = `${basePath}/chat/${conv.run_id}`; return ( - ))} + )}
diff --git a/templates/plugin-template/src/ui/app/pages/ChatPage.tsx b/templates/plugin-template/src/ui/app/pages/ChatPage.tsx index 86f4998ef..6a0012cb3 100644 --- a/templates/plugin-template/src/ui/app/pages/ChatPage.tsx +++ b/templates/plugin-template/src/ui/app/pages/ChatPage.tsx @@ -1,6 +1,6 @@ import { useCallback } from "react"; -import { ModernAgentConversation } from "@vertesia/ui/features"; -import { useNavigate, useParams } from "@vertesia/ui/router"; +import { GenericPageNavHeader, ModernAgentConversation } from "@vertesia/ui/features"; +import { NavLink, useNavigate, useParams } from "@vertesia/ui/router"; import { useUserSession } from "@vertesia/ui/session"; import { useUITranslation } from "@vertesia/ui/i18n"; import type { CreateAgentRunPayload } from "@vertesia/common"; @@ -31,6 +31,19 @@ export function ChatPage() { return (
+ {agentRunId && ( + + {t('nav.conversations')} + , + + {t('nav.conversation')} + , + ]} + /> + )} ; +} diff --git a/templates/plugin-template/src/ui/app/routes.tsx b/templates/plugin-template/src/ui/app/routes.tsx index 553fb8df4..aee7a6d2f 100644 --- a/templates/plugin-template/src/ui/app/routes.tsx +++ b/templates/plugin-template/src/ui/app/routes.tsx @@ -1,6 +1,7 @@ import { ChatPage } from "./pages/ChatPage"; import { ContentObjectDetailPage } from "./pages/ContentObjectDetailPage"; import { ContentObjectsPage } from "./pages/ContentObjectsPage"; +import { ConversationsPage } from "./pages/ConversationsPage"; import { HomePage } from "./pages/HomePage"; export const routes = [ @@ -16,6 +17,10 @@ export const routes = [ path: '/objects/:id', Component: () => , }, + { + path: '/conversations', + Component: () => , + }, { path: '/chat', Component: () => , diff --git a/templates/plugin-template/src/ui/i18n/locales/en.json b/templates/plugin-template/src/ui/i18n/locales/en.json index e0b5fa129..7812a8f44 100644 --- a/templates/plugin-template/src/ui/i18n/locales/en.json +++ b/templates/plugin-template/src/ui/i18n/locales/en.json @@ -6,10 +6,25 @@ "access.selectAccount": "Select Account", "access.selectProject": "Select Project", "access.switchPrompt": "Switch to a different account or project to access this app.", + "conversations.col.agent": "Agent", + "conversations.col.started": "Started", + "conversations.col.status": "Status", + "conversations.col.topic": "Topic", + "conversations.empty": "No conversations yet.", + "conversations.filterAgent": "Agent", + "conversations.filterByValue": "Filter by {{value}}", + "conversations.filterStatus": "Status", + "conversations.refresh": "Refresh", + "conversations.searchError": "Failed to search conversations.", + "conversations.searchPlaceholder": "Search conversations...", + "conversations.title": "Conversations", + "conversations.untitled": "Untitled conversation", "nav.conversation": "Conversation", + "nav.conversations": "All Conversations", "nav.home": "Home", "nav.newChat": "New Chat", "nav.objects": "Content Objects", + "nav.recent": "Recent", "nav.pluginAssistant": "Plugin Assistant", "nav.signOut": "Sign out", "nav.templateDescription": "This is the plugin template. Use it as a starting point to build your own plugin UI.", diff --git a/templates/plugin-template/src/ui/i18n/locales/fr.json b/templates/plugin-template/src/ui/i18n/locales/fr.json index 6b54a2c4a..32b0bc9d0 100644 --- a/templates/plugin-template/src/ui/i18n/locales/fr.json +++ b/templates/plugin-template/src/ui/i18n/locales/fr.json @@ -6,10 +6,25 @@ "access.selectAccount": "Sélectionner un compte", "access.selectProject": "Sélectionner un projet", "access.switchPrompt": "Changez de compte ou de projet pour accéder à cette application.", + "conversations.col.agent": "Agent", + "conversations.col.started": "Démarrée", + "conversations.col.status": "Statut", + "conversations.col.topic": "Sujet", + "conversations.empty": "Aucune conversation pour le moment.", + "conversations.filterAgent": "Agent", + "conversations.filterByValue": "Filtrer par {{value}}", + "conversations.filterStatus": "Statut", + "conversations.refresh": "Rafraîchir", + "conversations.searchError": "Échec de la recherche de conversations.", + "conversations.searchPlaceholder": "Rechercher des conversations...", + "conversations.title": "Conversations", + "conversations.untitled": "Conversation sans titre", "nav.conversation": "Conversation", + "nav.conversations": "Toutes les conversations", "nav.home": "Accueil", "nav.newChat": "Nouvelle conversation", "nav.objects": "Objets de contenu", + "nav.recent": "Récentes", "nav.pluginAssistant": "Assistant du plugin", "nav.signOut": "Se déconnecter", "nav.templateDescription": "Ceci est le modèle de plugin. Utilisez-le comme point de départ pour créer votre propre interface de plugin.", From 8ef3143d6c49e54b48367871c18e6df38c68392d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Vachette?= <5880528+michaelva@users.noreply.github.com> Date: Fri, 8 May 2026 16:47:15 +0900 Subject: [PATCH 68/75] fix merge --- .../ui/{ => app/layouts}/CommandPalette.tsx | 4 +- .../{ => app/layouts}/PersistentAssistant.tsx | 2 +- .../src/ui/app/layouts/PluginTopNav.tsx | 4 +- .../ui/{ => app/layouts}/assistantEvents.ts | 0 .../plugin-template/src/ui/app/routes.tsx | 22 +++++++++- .../src/ui/i18n/locales/en.json | 12 +++++- .../src/ui/i18n/locales/fr.json | 13 +++++- .../src/DevSessionProvider.tsx | 42 ------------------- 8 files changed, 48 insertions(+), 51 deletions(-) rename templates/plugin-template/src/ui/{ => app/layouts}/CommandPalette.tsx (98%) rename templates/plugin-template/src/ui/{ => app/layouts}/PersistentAssistant.tsx (99%) rename templates/plugin-template/src/ui/{ => app/layouts}/assistantEvents.ts (100%) delete mode 100644 templates/ui-plugin-template/src/DevSessionProvider.tsx diff --git a/templates/plugin-template/src/ui/CommandPalette.tsx b/templates/plugin-template/src/ui/app/layouts/CommandPalette.tsx similarity index 98% rename from templates/plugin-template/src/ui/CommandPalette.tsx rename to templates/plugin-template/src/ui/app/layouts/CommandPalette.tsx index fe15a8d19..8462c467c 100644 --- a/templates/plugin-template/src/ui/CommandPalette.tsx +++ b/templates/plugin-template/src/ui/app/layouts/CommandPalette.tsx @@ -3,8 +3,8 @@ import { Command, Search } from "lucide-react"; import { Modal } from "@vertesia/ui/core"; import { useUITranslation } from "@vertesia/ui/i18n"; import { useNavigate } from "@vertesia/ui/router"; -import { routes } from "./routes"; -import type { PluginRoute } from "./routes"; +import { routes } from "../routes"; +import type { PluginRoute } from "../routes"; interface PaletteItem { path: string; diff --git a/templates/plugin-template/src/ui/PersistentAssistant.tsx b/templates/plugin-template/src/ui/app/layouts/PersistentAssistant.tsx similarity index 99% rename from templates/plugin-template/src/ui/PersistentAssistant.tsx rename to templates/plugin-template/src/ui/app/layouts/PersistentAssistant.tsx index 53f302abc..0b09b21c5 100644 --- a/templates/plugin-template/src/ui/PersistentAssistant.tsx +++ b/templates/plugin-template/src/ui/app/layouts/PersistentAssistant.tsx @@ -6,7 +6,7 @@ import { useUITranslation } from "@vertesia/ui/i18n"; import { useLocation } from "@vertesia/ui/router"; import { useUserSession } from "@vertesia/ui/session"; import type { CreateAgentRunPayload } from "@vertesia/common"; -import { ASSISTANT_INTERACTION } from "./constants"; +import { ASSISTANT_INTERACTION } from "../constants"; import { OPEN_ASSISTANT_EVENT } from "./assistantEvents"; import type { OpenAssistantDetail } from "./assistantEvents"; diff --git a/templates/plugin-template/src/ui/app/layouts/PluginTopNav.tsx b/templates/plugin-template/src/ui/app/layouts/PluginTopNav.tsx index 57ff7a2cc..05da4b693 100644 --- a/templates/plugin-template/src/ui/app/layouts/PluginTopNav.tsx +++ b/templates/plugin-template/src/ui/app/layouts/PluginTopNav.tsx @@ -37,7 +37,9 @@ export function PluginTopNav({ return (
- +
+ +
{Env.name}
diff --git a/templates/plugin-template/src/ui/assistantEvents.ts b/templates/plugin-template/src/ui/app/layouts/assistantEvents.ts similarity index 100% rename from templates/plugin-template/src/ui/assistantEvents.ts rename to templates/plugin-template/src/ui/app/layouts/assistantEvents.ts diff --git a/templates/plugin-template/src/ui/app/routes.tsx b/templates/plugin-template/src/ui/app/routes.tsx index aee7a6d2f..d1aca107f 100644 --- a/templates/plugin-template/src/ui/app/routes.tsx +++ b/templates/plugin-template/src/ui/app/routes.tsx @@ -1,36 +1,56 @@ +import type { LucideIcon } from "lucide-react"; +import { Database, HomeIcon, MessagesSquare, PlusCircle } from "lucide-react"; +import type { Route } from "@vertesia/ui/router"; import { ChatPage } from "./pages/ChatPage"; import { ContentObjectDetailPage } from "./pages/ContentObjectDetailPage"; import { ContentObjectsPage } from "./pages/ContentObjectsPage"; import { ConversationsPage } from "./pages/ConversationsPage"; import { HomePage } from "./pages/HomePage"; -export const routes = [ +export type PluginRoute = Route & { + label?: string; + icon?: LucideIcon; + hideFromNav?: boolean; +}; + +export const routes: PluginRoute[] = [ { path: '/', + label: 'nav.home', + icon: HomeIcon, Component: () => , }, { path: '/objects', + label: 'nav.objects', + icon: Database, Component: () => , }, { path: '/objects/:id', + hideFromNav: true, Component: () => , }, { path: '/conversations', + label: 'nav.conversations', + icon: MessagesSquare, Component: () => , }, { path: '/chat', + label: 'nav.newChat', + icon: PlusCircle, Component: () => , }, { path: '/chat/:agentRunId', + hideFromNav: true, Component: () => , }, { path: '*', + hideFromNav: true, Component: () =>
Not found
, } ]; diff --git a/templates/plugin-template/src/ui/i18n/locales/en.json b/templates/plugin-template/src/ui/i18n/locales/en.json index 015838de8..b03fcef88 100644 --- a/templates/plugin-template/src/ui/i18n/locales/en.json +++ b/templates/plugin-template/src/ui/i18n/locales/en.json @@ -19,12 +19,20 @@ "conversations.searchPlaceholder": "Search conversations...", "conversations.title": "Conversations", "conversations.untitled": "Untitled conversation", + "nav.askAi": "Ask AI", + "nav.assistantContext": "Scope: {{scope}} • Route: {{route}}", + "nav.assistantInitialPrompt": "How can I help you today?", + "nav.assistantPlaceholder": "Ask the assistant...", + "nav.commandPaletteNoResults": "No matching commands.", + "nav.commandPalettePlaceholder": "Search commands...", "nav.conversation": "Conversation", "nav.conversations": "All Conversations", "nav.home": "Home", "nav.newAssistantSession": "Start a new assistant session", "nav.newChat": "New Chat", - "nav.objects": "Content Objects", + "nav.newSession": "New session", + "nav.objects": "Content", + "nav.openAssistant": "Open assistant", "nav.recent": "Recent", "nav.pluginAssistant": "Plugin Assistant", "nav.signOut": "Sign out", @@ -56,5 +64,5 @@ "objects.status.failed": "Failed", "objects.status.processing": "Processing", "objects.status.ready": "Ready", - "objects.title": "Content Objects" + "objects.title": "Content" } diff --git a/templates/plugin-template/src/ui/i18n/locales/fr.json b/templates/plugin-template/src/ui/i18n/locales/fr.json index 32b0bc9d0..b897e7dea 100644 --- a/templates/plugin-template/src/ui/i18n/locales/fr.json +++ b/templates/plugin-template/src/ui/i18n/locales/fr.json @@ -19,11 +19,20 @@ "conversations.searchPlaceholder": "Rechercher des conversations...", "conversations.title": "Conversations", "conversations.untitled": "Conversation sans titre", + "nav.askAi": "Demander à l'IA", + "nav.assistantContext": "Contexte : {{scope}} • Route : {{route}}", + "nav.assistantInitialPrompt": "Comment puis-je vous aider aujourd'hui ?", + "nav.assistantPlaceholder": "Posez une question à l'assistant...", + "nav.commandPaletteNoResults": "Aucune commande correspondante.", + "nav.commandPalettePlaceholder": "Rechercher des commandes...", "nav.conversation": "Conversation", "nav.conversations": "Toutes les conversations", "nav.home": "Accueil", + "nav.newAssistantSession": "Démarrer une nouvelle session d'assistant", "nav.newChat": "Nouvelle conversation", - "nav.objects": "Objets de contenu", + "nav.newSession": "Nouvelle session", + "nav.objects": "Contenu", + "nav.openAssistant": "Ouvrir l'assistant", "nav.recent": "Récentes", "nav.pluginAssistant": "Assistant du plugin", "nav.signOut": "Se déconnecter", @@ -55,5 +64,5 @@ "objects.status.failed": "Échec", "objects.status.processing": "En cours", "objects.status.ready": "Prêt", - "objects.title": "Objets de contenu" + "objects.title": "Contenu" } diff --git a/templates/ui-plugin-template/src/DevSessionProvider.tsx b/templates/ui-plugin-template/src/DevSessionProvider.tsx deleted file mode 100644 index e818ff13a..000000000 --- a/templates/ui-plugin-template/src/DevSessionProvider.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import type { AuthTokenPayload } from '@vertesia/common' -import { LastSelectedAccountId_KEY, LastSelectedProjectId_KEY, UserSession, UserSessionContext } from '@vertesia/ui/session' -import { useMemo } from 'react' -import type { ReactNode } from 'react' - -function decodeJwtPayload(token: string): AuthTokenPayload { - const [, payload] = token.split('.') - if (!payload) { - throw new Error('Invalid Vertesia auth token') - } - const padded = payload.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(payload.length / 4) * 4, '=') - return JSON.parse(atob(padded)) as AuthTokenPayload -} - -interface DevSessionProviderProps { - children: ReactNode - token: string -} - -export function DevSessionProvider({ children, token }: DevSessionProviderProps) { - const session = useMemo(() => { - const next = new UserSession() - next.isLoading = false - - try { - next.authToken = decodeJwtPayload(token) - next.client.withAuthCallback(() => Promise.resolve(`Bearer ${token}`)) - - localStorage.setItem(LastSelectedAccountId_KEY, next.authToken.account.id) - localStorage.setItem( - `${LastSelectedProjectId_KEY}-${next.authToken.account.id}`, - next.authToken.project?.id ?? '', - ) - } catch (error: unknown) { - next.authError = error instanceof Error ? error : new Error(String(error)) - } - - return next.clone() - }, [token]) - - return {children} -} From 49eac949b4558cb617f68dbd8e42180df5a043d2 Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Sat, 9 May 2026 18:30:38 +0900 Subject: [PATCH 69/75] add getAppInstallationPackage method and refactor RecentAgentRun type in PluginSidebar --- packages/client/src/AppsApi.ts | 12 +++++++++++ .../src/ui/app/layouts/PluginSidebar.tsx | 20 +++++++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/client/src/AppsApi.ts b/packages/client/src/AppsApi.ts index bd6725c5b..29e11b547 100644 --- a/packages/client/src/AppsApi.ts +++ b/packages/client/src/AppsApi.ts @@ -8,6 +8,7 @@ import type { AppManifest, AppManifestData, AppPackage, + AppPackageScope, AppToolCollection, AppVersionListQuery, AppVersionRecord, @@ -71,6 +72,17 @@ export default class AppsApi extends ApiTopic { return this.get(`/installations/${appInstallId}/tools`) } + /** + * Get package capabilities exposed by an app installation. + */ + getAppInstallationPackage(appInstallId: string, scope: AppPackageScope | AppPackageScope[] = 'all'): Promise { + return this.get(`/installations/${appInstallId}/package`, { + query: { + scope: Array.isArray(scope) ? scope.join(',') : scope, + }, + }); + } + /** * Fetch the always-on system tools package served by studio-server. * Tools and skills (`learn_*`) are returned on separate fields so UIs can diff --git a/templates/plugin-template/src/ui/app/layouts/PluginSidebar.tsx b/templates/plugin-template/src/ui/app/layouts/PluginSidebar.tsx index 0ceaa7b3d..8003d13b3 100644 --- a/templates/plugin-template/src/ui/app/layouts/PluginSidebar.tsx +++ b/templates/plugin-template/src/ui/app/layouts/PluginSidebar.tsx @@ -5,13 +5,29 @@ import { SidebarSection, useSidebarToggle } from '@vertesia/ui/layout'; import { useLocation, useRouterBasePath } from '@vertesia/ui/router'; import { useUserSession } from '@vertesia/ui/session'; import { Database, HomeIcon, MessageSquare, MessagesSquare, PlusCircle } from 'lucide-react'; -import type { AgentRunResponse, WorkflowRun } from '@vertesia/common'; +import type { WorkflowRun } from '@vertesia/common'; import { AppSidebarItem } from './AppSidebarItem'; import { ASSISTANT_INTERACTION } from '../constants'; const SIDEBAR_RECENT_LIMIT = 3; -function toWorkflowRun(run: AgentRunResponse): WorkflowRun { +type RecentAgentRun = { + id: string; + run_kind?: string; + workflow_id?: string; + status?: WorkflowRun['status']; + started_at?: string | number | Date | null; + completed_at?: string | number | Date | null; + topic?: string; + title?: string; + data?: Record; + interaction_name?: string; + visibility?: WorkflowRun['visibility']; + activity_state?: WorkflowRun['activity_state']; + interactive?: boolean; +}; + +function toWorkflowRun(run: RecentAgentRun): WorkflowRun { const isAgentRun = run.run_kind === 'agent'; return { From fe9a000e788ae61042446b6bd34f498d712c2d45 Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Sun, 10 May 2026 14:16:44 +0900 Subject: [PATCH 70/75] update reference URLs in llms.txt to point to the new design documentation --- packages/ui/llms.txt | 140 +++++++++++++++++++++---------------------- 1 file changed, 70 insertions(+), 70 deletions(-) diff --git a/packages/ui/llms.txt b/packages/ui/llms.txt index 023d945ed..e889e3dbc 100644 --- a/packages/ui/llms.txt +++ b/packages/ui/llms.txt @@ -5,14 +5,14 @@ ## Reference URLs -- Visual style guide: https://docs-ui-style-shadcn.vertesia.dev -- Markdown component guide: https://docs-ui-style-shadcn.vertesia.dev/components.md -- LLM reference: https://docs-ui-style-shadcn.vertesia.dev/llms.txt +- Visual style guide: https://design.vertesiahq.com +- Markdown component guide: https://design.vertesiahq.com/components.md +- LLM reference: https://design.vertesiahq.com/llms.txt # @vertesia/ui Component Reference > Markdown rendition of the Vertesia UI style guide for LLMs and text-first clients. -> Visual style guide: https://docs-ui-style-shadcn.vertesia.dev +> Visual style guide: https://design.vertesiahq.com Use this file when generating React UI for Vertesia apps or plugins. Prefer `@vertesia/ui` components over local duplicates, and use semantic color classes instead of hardcoded colors. @@ -31,65 +31,65 @@ import { useUserSession } from '@vertesia/ui/session'; ## Classes -- [Semantic](https://docs-ui-style-shadcn.vertesia.dev/semantic) -- [Background](https://docs-ui-style-shadcn.vertesia.dev/classes/background) -- [Text](https://docs-ui-style-shadcn.vertesia.dev/classes/text) -- [Border & Ring](https://docs-ui-style-shadcn.vertesia.dev/classes/border) -- [Icons](https://docs-ui-style-shadcn.vertesia.dev/classes/icons) +- [Semantic](https://design.vertesiahq.com/semantic) +- [Background](https://design.vertesiahq.com/classes/background) +- [Text](https://design.vertesiahq.com/classes/text) +- [Border & Ring](https://design.vertesiahq.com/classes/border) +- [Icons](https://design.vertesiahq.com/classes/icons) ## Components -- [Heading](https://docs-ui-style-shadcn.vertesia.dev/components/heading) -- [Buttons](https://docs-ui-style-shadcn.vertesia.dev/components/buttons) -- [Badges](https://docs-ui-style-shadcn.vertesia.dev/components/badges) -- [Dot Badges](https://docs-ui-style-shadcn.vertesia.dev/components/dotBadges) -- [Message Box](https://docs-ui-style-shadcn.vertesia.dev/components/messageBox) -- [Modal](https://docs-ui-style-shadcn.vertesia.dev/components/modal) -- [Confirm Modal](https://docs-ui-style-shadcn.vertesia.dev/components/confirmModal) -- [Delete Modal](https://docs-ui-style-shadcn.vertesia.dev/components/deleteModal) -- [Filter](https://docs-ui-style-shadcn.vertesia.dev/components/filter) -- [Tabs](https://docs-ui-style-shadcn.vertesia.dev/components/tabs) -- [Tooltips](https://docs-ui-style-shadcn.vertesia.dev/components/tooltips) -- [Switch](https://docs-ui-style-shadcn.vertesia.dev/components/switch) -- [Side Panel](https://docs-ui-style-shadcn.vertesia.dev/components/sidePanel) -- [SelectBox](https://docs-ui-style-shadcn.vertesia.dev/components/selectBox) -- [Overlay](https://docs-ui-style-shadcn.vertesia.dev/components/overlay) -- [Breadcrumbs](https://docs-ui-style-shadcn.vertesia.dev/components/breadcrumb) -- [Monaco](https://docs-ui-style-shadcn.vertesia.dev/components/monaco) -- [Card](https://docs-ui-style-shadcn.vertesia.dev/components/card) -- [Checkbox](https://docs-ui-style-shadcn.vertesia.dev/components/checkbox) -- [Collapsible](https://docs-ui-style-shadcn.vertesia.dev/components/collapsible) -- [Command](https://docs-ui-style-shadcn.vertesia.dev/components/command) -- [Dropdown](https://docs-ui-style-shadcn.vertesia.dev/components/dropdown) -- [Label](https://docs-ui-style-shadcn.vertesia.dev/components/label) -- [Panel](https://docs-ui-style-shadcn.vertesia.dev/components/panel) -- [Popover](https://docs-ui-style-shadcn.vertesia.dev/components/popover) -- [Radio Group](https://docs-ui-style-shadcn.vertesia.dev/components/radioGroup) -- [Resizeable](https://docs-ui-style-shadcn.vertesia.dev/components/resizeable) -- [Separator](https://docs-ui-style-shadcn.vertesia.dev/components/separator) -- [Text](https://docs-ui-style-shadcn.vertesia.dev/components/text) -- [Theme](https://docs-ui-style-shadcn.vertesia.dev/components/theme) +- [Heading](https://design.vertesiahq.com/components/heading) +- [Buttons](https://design.vertesiahq.com/components/buttons) +- [Badges](https://design.vertesiahq.com/components/badges) +- [Dot Badges](https://design.vertesiahq.com/components/dotBadges) +- [Message Box](https://design.vertesiahq.com/components/messageBox) +- [Modal](https://design.vertesiahq.com/components/modal) +- [Confirm Modal](https://design.vertesiahq.com/components/confirmModal) +- [Delete Modal](https://design.vertesiahq.com/components/deleteModal) +- [Filter](https://design.vertesiahq.com/components/filter) +- [Tabs](https://design.vertesiahq.com/components/tabs) +- [Tooltips](https://design.vertesiahq.com/components/tooltips) +- [Switch](https://design.vertesiahq.com/components/switch) +- [Side Panel](https://design.vertesiahq.com/components/sidePanel) +- [SelectBox](https://design.vertesiahq.com/components/selectBox) +- [Overlay](https://design.vertesiahq.com/components/overlay) +- [Breadcrumbs](https://design.vertesiahq.com/components/breadcrumb) +- [Monaco](https://design.vertesiahq.com/components/monaco) +- [Card](https://design.vertesiahq.com/components/card) +- [Checkbox](https://design.vertesiahq.com/components/checkbox) +- [Collapsible](https://design.vertesiahq.com/components/collapsible) +- [Command](https://design.vertesiahq.com/components/command) +- [Dropdown](https://design.vertesiahq.com/components/dropdown) +- [Label](https://design.vertesiahq.com/components/label) +- [Panel](https://design.vertesiahq.com/components/panel) +- [Popover](https://design.vertesiahq.com/components/popover) +- [Radio Group](https://design.vertesiahq.com/components/radioGroup) +- [Resizeable](https://design.vertesiahq.com/components/resizeable) +- [Separator](https://design.vertesiahq.com/components/separator) +- [Text](https://design.vertesiahq.com/components/text) +- [Theme](https://design.vertesiahq.com/components/theme) ## Layout & Structure -- [Avatar](https://docs-ui-style-shadcn.vertesia.dev/components/avatar) -- [Center](https://docs-ui-style-shadcn.vertesia.dev/components/center) -- [Divider](https://docs-ui-style-shadcn.vertesia.dev/components/divider) -- [Link](https://docs-ui-style-shadcn.vertesia.dev/components/link) -- [Portal](https://docs-ui-style-shadcn.vertesia.dev/components/portal) -- [Spinner](https://docs-ui-style-shadcn.vertesia.dev/components/spinner) +- [Avatar](https://design.vertesiahq.com/components/avatar) +- [Center](https://design.vertesiahq.com/components/center) +- [Divider](https://design.vertesiahq.com/components/divider) +- [Link](https://design.vertesiahq.com/components/link) +- [Portal](https://design.vertesiahq.com/components/portal) +- [Spinner](https://design.vertesiahq.com/components/spinner) ## Forms -- [FormItem](https://docs-ui-style-shadcn.vertesia.dev/components/formItem) -- [Inputs](https://docs-ui-style-shadcn.vertesia.dev/components/inputs) -- [Textarea](https://docs-ui-style-shadcn.vertesia.dev/components/textarea) -- [ComboBox](https://docs-ui-style-shadcn.vertesia.dev/components/comboBox) -- [InputList](https://docs-ui-style-shadcn.vertesia.dev/components/inputList) -- [MenuList](https://docs-ui-style-shadcn.vertesia.dev/components/menuList) -- [NumberInput](https://docs-ui-style-shadcn.vertesia.dev/components/numberInput) -- [SelectList](https://docs-ui-style-shadcn.vertesia.dev/components/selectList) -- [TagsInput](https://docs-ui-style-shadcn.vertesia.dev/components/tagsInput) +- [FormItem](https://design.vertesiahq.com/components/formItem) +- [Inputs](https://design.vertesiahq.com/components/inputs) +- [Textarea](https://design.vertesiahq.com/components/textarea) +- [ComboBox](https://design.vertesiahq.com/components/comboBox) +- [InputList](https://design.vertesiahq.com/components/inputList) +- [MenuList](https://design.vertesiahq.com/components/menuList) +- [NumberInput](https://design.vertesiahq.com/components/numberInput) +- [SelectList](https://design.vertesiahq.com/components/selectList) +- [TagsInput](https://design.vertesiahq.com/components/tagsInput) # Component Guidance @@ -97,7 +97,7 @@ import { useUserSession } from '@vertesia/ui/session'; Primary action primitive with Vertesia variants, loading state, disabled state, and tooltip support. -- Route: https://docs-ui-style-shadcn.vertesia.dev/components/buttons +- Route: https://design.vertesiahq.com/components/buttons - Import path: `@vertesia/ui/core` - Exports: `Button`, `CopyButton`, `buttonVariants` @@ -147,7 +147,7 @@ import { Button } from '@vertesia/ui/core'; Single-line text input with a simplified value-based `onChange` callback. -- Route: https://docs-ui-style-shadcn.vertesia.dev/components/inputs +- Route: https://design.vertesiahq.com/components/inputs - Import path: `@vertesia/ui/core` - Exports: `Input` @@ -184,7 +184,7 @@ const [name, setName] = useState(''); Multi-line text control using standard React textarea event handling. -- Route: https://docs-ui-style-shadcn.vertesia.dev/components/textarea +- Route: https://design.vertesiahq.com/components/textarea - Import path: `@vertesia/ui/core` - Exports: `Textarea` @@ -215,7 +215,7 @@ Multi-line text control using standard React textarea event handling. Field wrapper for labels, required indicators, and help tooltips. -- Route: https://docs-ui-style-shadcn.vertesia.dev/components/formItem +- Route: https://design.vertesiahq.com/components/formItem - Import path: `@vertesia/ui/core` - Exports: `FormItem` @@ -248,7 +248,7 @@ Field wrapper for labels, required indicators, and help tooltips. Accessible overlay dialog for focused tasks and forms. -- Route: https://docs-ui-style-shadcn.vertesia.dev/components/modal +- Route: https://design.vertesiahq.com/components/modal - Import path: `@vertesia/ui/core` - Exports: `Modal`, `ModalTitle`, `ModalBody`, `ModalFooter` @@ -293,7 +293,7 @@ Accessible overlay dialog for focused tasks and forms. Confirmation dialog for actions that need explicit user consent. -- Route: https://docs-ui-style-shadcn.vertesia.dev/components/confirmModal +- Route: https://design.vertesiahq.com/components/confirmModal - Import path: `@vertesia/ui/core` - Exports: `ConfirmModal` @@ -316,7 +316,7 @@ Confirmation dialog for actions that need explicit user consent. Delete-specific confirmation wrapper with async delete handling and toast feedback. -- Route: https://docs-ui-style-shadcn.vertesia.dev/components/deleteModal +- Route: https://design.vertesiahq.com/components/deleteModal - Import path: `@vertesia/ui/core` - Exports: `DeleteModal` @@ -342,7 +342,7 @@ Delete-specific confirmation wrapper with async delete handling and toast feedba Dropdown select for string or object options, with optional search, clear, and multi-select modes. -- Route: https://docs-ui-style-shadcn.vertesia.dev/components/selectBox +- Route: https://design.vertesiahq.com/components/selectBox - Import path: `@vertesia/ui/core` - Exports: `SelectBox` @@ -383,7 +383,7 @@ Dropdown select for string or object options, with optional search, clear, and m Tab container driven by an array of tab objects and companion bar/panel components. -- Route: https://docs-ui-style-shadcn.vertesia.dev/components/tabs +- Route: https://design.vertesiahq.com/components/tabs - Import path: `@vertesia/ui/core` - Exports: `Tabs`, `TabsBar`, `TabsPanel`, `VTabs`, `VTabsBar`, `VTabsPanel` @@ -408,7 +408,7 @@ Tab container driven by an array of tab objects and companion bar/panel componen Compact status and category labels. -- Route: https://docs-ui-style-shadcn.vertesia.dev/components/badges +- Route: https://design.vertesiahq.com/components/badges - Import path: `@vertesia/ui/core` - Exports: `Badge`, `DotBadge` @@ -425,7 +425,7 @@ Compact status and category labels. Semantic feedback box for success, attention, destructive, done, info, and muted messages. -- Route: https://docs-ui-style-shadcn.vertesia.dev/components/messageBox +- Route: https://design.vertesiahq.com/components/messageBox - Import path: `@vertesia/ui/core` - Exports: `MessageBox` @@ -437,7 +437,7 @@ Semantic feedback box for success, attention, destructive, done, info, and muted Choice controls for boolean values and mutually exclusive option sets. -- Route: https://docs-ui-style-shadcn.vertesia.dev/components/checkbox +- Route: https://design.vertesiahq.com/components/checkbox - Import path: `@vertesia/ui/core` - Exports: `Checkbox`, `Switch`, `RadioGroup`, `RadioGroupItem`, `RadioGroupOption` @@ -451,7 +451,7 @@ Choice controls for boolean values and mutually exclusive option sets. Overlay primitives for action menus, floating content, command palettes, and contextual help. -- Route: https://docs-ui-style-shadcn.vertesia.dev/components/dropdown +- Route: https://design.vertesiahq.com/components/dropdown - Import path: `@vertesia/ui/core` - Exports: `Dropdown`, `MenuGroup`, `MenuItem`, `Popover`, `Command`, `VTooltip`, `Tooltip` @@ -466,7 +466,7 @@ Overlay primitives for action menus, floating content, command palettes, and con Content containers for repeated items and framed tool surfaces. -- Route: https://docs-ui-style-shadcn.vertesia.dev/components/card +- Route: https://design.vertesiahq.com/components/card - Import path: `@vertesia/ui/core` - Exports: `Card`, `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `CardFooter`, `Panel` @@ -479,7 +479,7 @@ Content containers for repeated items and framed tool surfaces. Small primitives for display, separation, links, loading states, portals, and overlays. -- Route: https://docs-ui-style-shadcn.vertesia.dev/components/center +- Route: https://design.vertesiahq.com/components/center - Import path: `@vertesia/ui/core` - Exports: `Avatar`, `SvgAvatar`, `Center`, `Divider`, `Link`, `Portal`, `Spinner`, `Separator`, `Overlay` @@ -493,7 +493,7 @@ Small primitives for display, separation, links, loading states, portals, and ov Semantic Tailwind classes and approved lucide-react icon guidance for Vertesia apps. -- Route: https://docs-ui-style-shadcn.vertesia.dev/semantic +- Route: https://design.vertesiahq.com/semantic - Import path: `@vertesia/ui/core` - Exports: `ModeToggle` From 4fe19c1d836501584e0568e63f96a590b72d6e10 Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Tue, 12 May 2026 19:21:53 +0900 Subject: [PATCH 71/75] feat: Git credential helper for app repos + AppPackageScope + plugin template updates - Add vertesia auth git and auth git credential commands for Git HTTPS auth to app repositories - Add AppPackageScope and getAppInstallationPackage to AppsApi - Update plugin template (CLAUDE.md, README, vite config, env) - Remove dev-* environment choices from profile target prompts - Minor admin UI and access-control updates --- packages/cli/src/index.ts | 17 +- packages/cli/src/profiles/commands.ts | 4 +- packages/cli/src/profiles/git.ts | 202 ++++++++++++++++++ packages/cli/src/profiles/index.ts | 31 +-- packages/client/src/client.ts | 4 +- packages/common/src/access-control.ts | 2 + packages/common/src/apikey.ts | 1 + packages/common/src/apps.ts | 13 +- packages/tools-admin-ui/src/AdminApp.tsx | 2 +- packages/tools-admin-ui/src/hooks.ts | 26 ++- packages/ui/src/env/index.ts | 1 + templates/plugin-template/CLAUDE.md | 20 ++ .../plugin-template/src/ui/app/README.md | 13 ++ templates/plugin-template/src/ui/env.ts | 6 +- .../plugin-template/src/ui/vite-env.d.ts | 6 +- templates/plugin-template/vite.config.ts | 9 - 16 files changed, 298 insertions(+), 59 deletions(-) create mode 100644 packages/cli/src/profiles/git.ts diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 430bb77fd..556c6226e 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -24,6 +24,7 @@ import { useProfile, type CreateProfileOptions, } from './profiles/commands.js'; +import { configureGitAuth, serveGitCredential } from './profiles/git.js'; import { AVAILABLE_REGIONS, DEFAULT_REGION, getConfigFile } from './profiles/index.js'; import { listProjects, useProject } from './projects/index.js'; import runInteraction from './run/index.js'; @@ -58,7 +59,7 @@ const authRoot = program.command("auth") authRoot.command("login [profile]") .description("Authenticate a profile, creating it when it does not exist") - .option("-t, --target ", "The target environment for a new profile. Possible values are: local, dev-main, dev-preview, preview, prod or a custom URL.") + .option("-t, --target ", "The target environment for a new profile. Possible values are: local, preview, prod, or a custom URL.") .option("-r, --region ", `Deployment region for a new profile: ${AVAILABLE_REGIONS.join(', ')}. Defaults to ${DEFAULT_REGION}. Only applies to preview and prod targets.`) .option("-p, --project ", "Authenticate for the given project ID") .option("-a, --account ", "The account ID to use when creating a profile") @@ -89,6 +90,18 @@ authRoot.command("refresh") .option("-p, --project ", "Refresh the current profile token for the given project ID") .action((options: { project?: string }) => updateCurrentProfile(undefined, undefined, options)) +const authGit = authRoot.command("git") + .description("Configure Git to authenticate to Vertesia app repositories with the active profile token.") + .option("-p, --profile ", "Profile to use for Git credentials. Defaults to the active profile.") + .option("--url ", "Git server base URL, for example https://git.dev1.vertesia.io.") + .option("--no-alias", "Do not configure the vertesia:.git short alias.") + .action((options: { profile?: string; url?: string; alias?: boolean }) => configureGitAuth(options)); + +authGit.command("credential [action]") + .description("Git credential helper entrypoint. Called by git; users should run `vertesia auth git` instead.") + .option("-p, --profile ", "Profile to use for credentials.") + .action((action: string | undefined, options: { profile?: string }) => serveGitCredential(action, options)); + program.command("envs [envId]") .description("List the environments you have access to") .action((envId: string | undefined, options: Record) => { @@ -169,7 +182,7 @@ profilesRoot.command('use [name]') }); profilesRoot.command('add [name]') .alias('create') - .option("-t, --target ", "The target environment for the profile. Possible values are: local, dev-main, dev-preview, preview, prod or a custom URL.") + .option("-t, --target ", "The target environment for the profile. Possible values are: local, preview, prod, or a custom URL.") .option("-r, --region ", `Deployment region: ${AVAILABLE_REGIONS.join(', ')}. Defaults to ${DEFAULT_REGION}. Only applies to preview and prod targets.`) .option("-k, --apikey ", "The API key or auth token to use for the profile") .option("-p, --project ", "The project ID to use for the profile") diff --git a/packages/cli/src/profiles/commands.ts b/packages/cli/src/profiles/commands.ts index 0caf79264..858fbd7db 100644 --- a/packages/cli/src/profiles/commands.ts +++ b/packages/cli/src/profiles/commands.ts @@ -281,8 +281,8 @@ export async function createProfile(name?: string, options: CreateProfileOptions }); } if (!options.target) { - // only show dev environments in dev mode - const choices = config.isDevMode ? ['local', 'dev-main', 'dev-preview', 'preview', 'prod', 'custom'] : ['preview', 'prod']; + // Branch/dev deployments are custom URLs so profile config is explicit. + const choices = config.isDevMode ? ['local', 'preview', 'prod', 'custom'] : ['preview', 'prod', 'custom']; questions.push({ type: 'select', name: 'target', diff --git a/packages/cli/src/profiles/git.ts b/packages/cli/src/profiles/git.ts new file mode 100644 index 000000000..ccc21ade2 --- /dev/null +++ b/packages/cli/src/profiles/git.ts @@ -0,0 +1,202 @@ +import { execFileSync } from 'node:child_process'; +import process from 'node:process'; +import { ensureProfileAccessToken } from './auth.js'; +import { config, type Profile } from './index.js'; + +interface GitAuthOptions { + profile?: string; + url?: string; + alias?: boolean; +} + +interface GitCredentialInput { + protocol?: string; + host?: string; + path?: string; +} + +const KNOWN_GIT_BASE_URLS = [ + 'https://git.vertesia.io', + 'https://git.us1.vertesia.io', + 'https://git.eu1.vertesia.io', + 'https://git.jp1.vertesia.io', + 'https://git.dev1.vertesia.io', +]; + +export async function configureGitAuth(options: GitAuthOptions = {}) { + const profile = getRequestedProfile(options.profile); + const gitBaseUrl = normalizeGitBaseUrl(options.url || gitServerUrlForProfile(profile)); + const helper = `!vertesia auth git credential --profile ${shellQuote(profile.name)}`; + + gitConfig('--global', `credential.${gitBaseUrl}.helper`, helper); + gitConfig('--global', `credential.${gitBaseUrl}.useHttpPath`, 'true'); + + if (options.alias !== false) { + removeKnownVertesiaAliases(); + gitConfig('--global', `url.${gitBaseUrl}/.insteadOf`, 'vertesia:'); + } + + console.log(`Configured Git authentication for ${gitBaseUrl}`); + console.log(`Clone with: git clone ${gitBaseUrl}/.git`); + if (options.alias !== false) { + console.log('Short alias: git clone vertesia:.git'); + } +} + +export async function serveGitCredential(action: string | undefined, options: Pick = {}) { + if (!action || action === 'get') { + const envToken = process.env.VERTESIA_AUTH_TOKEN || process.env.VERTESIA_TOKEN; + if (envToken) { + writeCredential(envToken); + return; + } + + const input = await readCredentialInput(); + const profile = pickProfileForCredential(input, options.profile); + if (!profile) { + throw new Error( + `No Vertesia profile matches git host ${input.host || ''}. Run \`vertesia auth git\`.`, + ); + } + + const token = await ensureProfileAccessToken(profile); + if (!token) { + throw new Error(`Profile ${profile.name} has no usable auth token. Run \`vertesia auth refresh\`.`); + } + writeCredential(token); + return; + } + + // Git may call helpers with store/erase. Tokens are managed by Vertesia CLI + // profile auth, so there is nothing useful to persist from Git. +} + +function getRequestedProfile(profileName?: string): Profile { + if (profileName) { + const profile = config.getProfile(profileName); + if (!profile) throw new Error(`Profile ${profileName} not found.`); + return profile; + } + if (!config.current) { + throw new Error( + 'No Vertesia profile is selected. Run `vertesia auth login` or `vertesia profiles use `.', + ); + } + return config.current; +} + +function pickProfileForCredential(input: GitCredentialInput, profileName?: string): Profile | undefined { + if (profileName) { + return config.getProfile(profileName); + } + const host = input.host?.toLowerCase(); + if (!host) return config.current; + if (config.current && gitHostForProfile(config.current) === host) { + return config.current; + } + return config.profiles.find(profile => gitHostForProfile(profile) === host); +} + +function gitHostForProfile(profile: Profile): string | undefined { + try { + return new URL(gitServerUrlForProfile(profile)).hostname.toLowerCase(); + } catch { + return undefined; + } +} + +function gitServerUrlForProfile(profile: Profile): string { + const override = process.env.VERTESIA_GIT_SERVER_URL + || process.env.APP_GIT_SERVER_URL + || process.env.APPGEN_GIT_SERVER_URL; + if (override) return normalizeGitBaseUrl(override); + + const sourceUrl = profile.studio_server_url || profile.zeno_server_url || profile.config_url; + try { + const url = new URL(sourceUrl); + const host = url.hostname.toLowerCase(); + if (host === 'localhost' || host === '127.0.0.1' || host.endsWith('.local')) { + return 'https://git.dev1.vertesia.io'; + } + if (host === 'api.vertesia.io' || host === 'api-preview.vertesia.io') { + return 'https://git.vertesia.io'; + } + if (host.startsWith('api-preview.')) { + return `https://${host.replace(/^api-preview\./, 'git.')}`; + } + if (host.startsWith('api.')) { + return `https://${host.replace(/^api\./, 'git.')}`; + } + + const parts = host.split('.'); + const apiIndex = parts.indexOf('api'); + const region = apiIndex >= 0 ? parts[apiIndex + 1] : undefined; + if (region && parts[apiIndex + 2] === 'vertesia') { + return `https://git.${region}.vertesia.io`; + } + } catch { + // Fall through to profile region/default below. + } + + if (profile.region) return `https://git.${profile.region}.vertesia.io`; + return 'https://git.dev1.vertesia.io'; +} + +function normalizeGitBaseUrl(value: string): string { + const normalized = value.replace(/\/+$/, ''); + if (!normalized.startsWith('http://') && !normalized.startsWith('https://')) { + throw new Error(`Git server URL must be an http(s) URL: ${value}`); + } + return normalized; +} + +function gitConfig(...args: string[]) { + execFileSync('git', ['config', ...args], { stdio: 'pipe' }); +} + +function removeKnownVertesiaAliases() { + for (const gitBaseUrl of KNOWN_GIT_BASE_URLS) { + try { + execFileSync('git', ['config', '--global', '--unset-all', `url.${gitBaseUrl}/.insteadOf`, 'vertesia:'], { + stdio: 'ignore', + }); + } catch { + // The alias may not exist yet. + } + } +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +async function readCredentialInput(): Promise { + const text = await readStdin(); + const input: Record = {}; + for (const line of text.split(/\r?\n/)) { + if (!line) break; + const separator = line.indexOf('='); + if (separator < 0) continue; + input[line.slice(0, separator)] = line.slice(separator + 1); + } + return input; +} + +function readStdin(): Promise { + return new Promise((resolve, reject) => { + let text = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', chunk => { + text += chunk; + }); + process.stdin.on('end', () => resolve(text)); + process.stdin.on('error', reject); + if (process.stdin.isTTY) { + resolve(''); + } + }); +} + +function writeCredential(token: string) { + process.stdout.write(`username=vertesia\npassword=${token}\n`); +} diff --git a/packages/cli/src/profiles/index.ts b/packages/cli/src/profiles/index.ts index d21624504..cd36145fa 100644 --- a/packages/cli/src/profiles/index.ts +++ b/packages/cli/src/profiles/index.ts @@ -21,18 +21,11 @@ export type Region = 'us1' | 'eu1' | 'jp1'; export const DEFAULT_REGION: Region = 'us1'; export const AVAILABLE_REGIONS: Region[] = ['us1', 'eu1', 'jp1']; -export type ConfigUrlRef = "local" | "dev-main" | "dev-preview" | "preview" | "prod" | string; +export type ConfigUrlRef = "local" | "preview" | "prod" | string; export function getConfigUrl(value: ConfigUrlRef, region: Region = DEFAULT_REGION): string { - if (isDevDeploymentTarget(value)) { - return `https://${value}.ui.dev1.vertesia.io/cli`; - } switch (value) { case "local": return "https://localhost:5173/cli"; - case "dev-main": - return "https://dev-main.ui.dev1.vertesia.io/cli"; - case "dev-preview": - return "https://dev-preview.ui.dev1.vertesia.io/cli"; case "preview": return `https://preview.cloud.${region}.vertesia.io/cli`; case "prod": @@ -46,28 +39,12 @@ export function getConfigUrl(value: ConfigUrlRef, region: Region = DEFAULT_REGIO } } export function getServerUrls(value: ConfigUrlRef, region: Region = DEFAULT_REGION): { studio_server_url: string; zeno_server_url: string } { - if (isDevDeploymentTarget(value)) { - return { - studio_server_url: `https://studio-server-${value}.api.dev1.vertesia.io`, - zeno_server_url: `https://zeno-server-${value}.api.dev1.vertesia.io`, - }; - } switch (value) { case "local": return { studio_server_url: "http://localhost:8091", zeno_server_url: "http://localhost:8092", }; - case "dev-main": - return { - studio_server_url: "https://studio-server-dev-main.api.dev1.vertesia.io", - zeno_server_url: "https://zeno-server-dev-main.api.dev1.vertesia.io", - }; - case "dev-preview": - return { - studio_server_url: "https://studio-server-dev-preview.api.dev1.vertesia.io", - zeno_server_url: "https://zeno-server-dev-preview.api.dev1.vertesia.io", - }; case "preview": return { studio_server_url: `https://api-preview.${region}.vertesia.io`, @@ -83,12 +60,8 @@ export function getServerUrls(value: ConfigUrlRef, region: Region = DEFAULT_REGI } } -function isDevDeploymentTarget(value: string): boolean { - return value.startsWith('dev-'); -} - export function getCloudTypeFromConfigUrl(url: string) { - if (url.startsWith("https://localhost")) { + if (url.startsWith("http://localhost") || url.startsWith("https://localhost")) { return "staging"; } else if (url.includes(".ui.dev1.vertesia.io")) { return "staging"; diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 0684e799b..34ea015b4 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -102,7 +102,7 @@ export class VertesiaClient extends AbstractFetchClient { static async fromAuthToken( token: string, payload?: AuthTokenPayload, - endpoints?: { studio: string; store: string; token?: string } + endpoints?: { studio: string; store: string; token?: string; git?: string } ) { if (!payload) { payload = decodeJWT(token); @@ -454,6 +454,7 @@ function getEndpointsFromDomain(domain: string) { studio: `http://localhost:8091`, store: `http://localhost:8092`, token: getRuntimeStsUrl() ?? "https://sts.dev1.vertesia.io", + git: "https://git.dev1.vertesia.io", }; } else { const url = `https://${domain}`; @@ -463,6 +464,7 @@ function getEndpointsFromDomain(domain: string) { studio: url, store: url, token: url.replace("api", "sts"), + git: url.replace("api", "git"), }; } } diff --git a/packages/common/src/access-control.ts b/packages/common/src/access-control.ts index ba83807b5..92e0e71d2 100644 --- a/packages/common/src/access-control.ts +++ b/packages/common/src/access-control.ts @@ -17,6 +17,8 @@ export enum Permission { env_admin = "environment:admin", + app_manage = "app:manage", + project_admin = "project:admin", project_integration_read = "project:integration_read", project_settings_write = "project:settings_write", diff --git a/packages/common/src/apikey.ts b/packages/common/src/apikey.ts index 10fb2ea95..6856243b5 100644 --- a/packages/common/src/apikey.ts +++ b/packages/common/src/apikey.ts @@ -107,6 +107,7 @@ export interface AuthTokenPayload { studio: string; store: string; token?: string; + git?: string; }; iss: string; //issuer diff --git a/packages/common/src/apps.ts b/packages/common/src/apps.ts index a5a78737b..7ba5b8a7b 100644 --- a/packages/common/src/apps.ts +++ b/packages/common/src/apps.ts @@ -395,20 +395,29 @@ export interface RemoteActivityDefinition { export type AppCapabilities = 'ui' | 'tools' | 'interactions' | 'types' | 'processes' | 'templates' | 'dashboards'; export type AppAvailableIn = 'app_portal' | 'composite_app'; -export type AppVersionKind = 'preview' | 'published'; +export type AppVersionKind = 'design' | 'preview' | 'published'; export type AppVersionState = 'ready' | 'failed' | 'expired'; export type AppVersionTarget = 'static' | 'service'; export interface AppVersionStorage { tenant_id?: string; app_prefix?: string; + artifacts_prefix?: string; source_archive?: string; + source_git?: AppVersionGitSource; build_prefix?: string; manifest_path?: string; service_archive?: string; live_metadata_path?: string; } +export interface AppVersionGitSource { + url?: string; + remote?: string; + ref?: string; + commit?: string; +} + export interface AppVersionUrls { live_url?: string; app_url?: string; @@ -636,6 +645,8 @@ export interface Endpoints { token?: string; /** The browser-facing Studio UI (composable-ui) base URL */ ui?: string; + /** The Smart HTTP app source git server base URL */ + git?: string; } /** diff --git a/packages/tools-admin-ui/src/AdminApp.tsx b/packages/tools-admin-ui/src/AdminApp.tsx index a9b2cd144..a56b0612b 100644 --- a/packages/tools-admin-ui/src/AdminApp.tsx +++ b/packages/tools-admin-ui/src/AdminApp.tsx @@ -49,7 +49,7 @@ export function AdminApp({ baseUrl = '/api' }: AdminAppProps) { const { data: serverInfo, isLoading: loadingInfo, error: infoError } = useServerInfo(baseUrl); const { data: resourceData, isLoading: loadingData, error: dataError } = useResourceData( baseUrl, - serverInfo?.endpoints.mcp, + serverInfo?.endpoints?.mcp, ); const isLoading = loadingInfo || loadingData; diff --git a/packages/tools-admin-ui/src/hooks.ts b/packages/tools-admin-ui/src/hooks.ts index e9c7baa22..16278bc19 100644 --- a/packages/tools-admin-ui/src/hooks.ts +++ b/packages/tools-admin-ui/src/hooks.ts @@ -7,12 +7,22 @@ import { useFetch } from '@vertesia/ui/core'; import type { ResourceData, ServerInfo } from './types.js'; import { buildResourceData } from './types.js'; +type ResourceDataArgs = Parameters; + +async function fetchJson(url: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Request failed: ${response.status} ${response.statusText}`); + } + return response.json() as Promise; +} + /** * Fetches the tool server info (message, version, endpoints). */ export function useServerInfo(baseUrl: string) { return useFetch(() => - fetch(baseUrl).then(r => r.json()), + fetchJson(baseUrl), [baseUrl] ); } @@ -23,14 +33,14 @@ export function useServerInfo(baseUrl: string) { */ export function useResourceData(baseUrl: string, mcpEndpoints?: string[]) { return useFetch(() => { - const fetchJson = (path: string) => fetch(`${baseUrl}/${path}`).then(r => r.json()); + const fetchResource = (path: string) => fetchJson(`${baseUrl}/${path}`); return Promise.all([ - fetchJson('interactions'), - fetchJson('tools'), - fetchJson('skills'), - fetchJson('activities'), - fetchJson('types'), - fetchJson('templates'), + fetchResource('interactions'), + fetchResource('tools'), + fetchResource('skills'), + fetchResource('activities'), + fetchResource('types'), + fetchResource('templates'), ]).then(([interactions, tools, skills, activities, types, templates]) => buildResourceData(interactions, tools, skills, activities, types, templates, mcpEndpoints) ); diff --git a/packages/ui/src/env/index.ts b/packages/ui/src/env/index.ts index 8079c9dba..c2c92492a 100644 --- a/packages/ui/src/env/index.ts +++ b/packages/ui/src/env/index.ts @@ -15,6 +15,7 @@ export interface EnvProps { zeno: string, studio: string, sts: string, // Security Token Service endpoint + git?: string, // Smart HTTP app source git endpoint }, firebase?: { apiKey: string, diff --git a/templates/plugin-template/CLAUDE.md b/templates/plugin-template/CLAUDE.md index a541bc4c1..56ba895e5 100644 --- a/templates/plugin-template/CLAUDE.md +++ b/templates/plugin-template/CLAUDE.md @@ -87,6 +87,26 @@ Rules of thumb: - A primitive used by ≥2 features (e.g. a sortable header) → promote to `app/components/`. - A hook used by ≥2 features → promote to `app/hooks/`. +## Visual Defaults + +Generated apps should look like compact Vertesia Studio product surfaces. Default +to a light operational UI with restrained brand accents unless the user +explicitly asks for a dark, immersive, or heavily branded treatment. + +- Use `@vertesia/ui` components and semantic tokens (`bg-background`, + `bg-card`, `border-border`, `text-muted`, `text-success`, `text-attention`, + `text-destructive`) before hardcoded colors. +- Prefer tables, queues, filters, split panes, detail pages, and process + timelines over hero sections, oversized cards, or presentation-style + dashboards. +- Keep typography dense and restrained. Use `text-xl`/`text-2xl` for page + titles and smaller headings inside panels. +- Do not force black/near-black backgrounds, neon palettes, or dark-first + panels unless requested. If dark mode is useful, implement it with `.dark` + variants and semantic tokens. +- Translate customer brand material into small accents, badges, labels, chart + colors, and optional theme variables rather than full-page theming. + ## Plugin-Specific Conventions - ESM with `.js` import extensions in tool-server code: `import { x } from "./foo.js"` diff --git a/templates/plugin-template/src/ui/app/README.md b/templates/plugin-template/src/ui/app/README.md index a4abcde8a..5cbdd9ed6 100644 --- a/templates/plugin-template/src/ui/app/README.md +++ b/templates/plugin-template/src/ui/app/README.md @@ -30,3 +30,16 @@ app/ 5. Wire the route in `app/routes.tsx`. For UI patterns and component conventions, see the `vertesia-ui` skill. + +## Visual defaults + +Build app screens as compact Vertesia Studio product surfaces. The default is a +light operational UI with restrained brand accents. + +- Use `@vertesia/ui` components and semantic tokens before hardcoded colors. +- Prefer dense tables, queues, filters, split panes, detail pages, and process + timelines over hero sections or oversized dashboard cards. +- Keep page titles around `text-xl`/`text-2xl`; use smaller headings inside + panels. +- Do not force black/near-black backgrounds, neon palettes, or dark-first panels + unless explicitly requested. diff --git a/templates/plugin-template/src/ui/env.ts b/templates/plugin-template/src/ui/env.ts index c8357dde8..6db02c53e 100644 --- a/templates/plugin-template/src/ui/env.ts +++ b/templates/plugin-template/src/ui/env.ts @@ -17,9 +17,9 @@ const envConfig: Parameters[0] & { devAuthToken?: string } = { type: "development", devAuthToken: isDev ? import.meta.env.VITE_VERTESIA_AUTH_TOKEN : undefined, endpoints: { - studio: import.meta.env.VITE_STUDIO_URL ?? CONFIG__STUDIO_URL, - zeno: import.meta.env.VITE_ZENO_URL ?? CONFIG__ZENO_URL, - sts: import.meta.env.VITE_STS_URL ?? CONFIG__STS_URL, + studio: import.meta.env.VITE_VERTESIA_STUDIO_URL ?? CONFIG__STUDIO_URL, + zeno: import.meta.env.VITE_VERTESIA_ZENO_URL ?? CONFIG__ZENO_URL, + sts: import.meta.env.VITE_VERTESIA_STS_URL ?? CONFIG__STS_URL, } }; diff --git a/templates/plugin-template/src/ui/vite-env.d.ts b/templates/plugin-template/src/ui/vite-env.d.ts index 2c649d67d..5084bfd42 100644 --- a/templates/plugin-template/src/ui/vite-env.d.ts +++ b/templates/plugin-template/src/ui/vite-env.d.ts @@ -2,9 +2,9 @@ interface ImportMetaEnv { readonly VITE_APP_NAME: string; - readonly VITE_STUDIO_URL?: string; - readonly VITE_ZENO_URL?: string; - readonly VITE_STS_URL?: string; + readonly VITE_VERTESIA_STUDIO_URL?: string; + readonly VITE_VERTESIA_ZENO_URL?: string; + readonly VITE_VERTESIA_STS_URL?: string; } interface ImportMeta { diff --git a/templates/plugin-template/vite.config.ts b/templates/plugin-template/vite.config.ts index 6deb372d9..d2f3d225b 100644 --- a/templates/plugin-template/vite.config.ts +++ b/templates/plugin-template/vite.config.ts @@ -93,9 +93,6 @@ function defineAppConfig({ command }: ConfigEnv): UserConfig { // framework dev server over HTTP, so both modes disable HTTPS. const useHttps = process.env.DEV_MODE !== '1' && process.env.VERCEL !== '1'; const base = command === 'build' ? '/app/' : '/'; - const devApiTarget = process.env.VERTESIA_STUDIO_PROXY_TARGET - ?? process.env.VITE_VERTESIA_STUDIO_PROXY_TARGET - ?? 'https://api.dev1.vertesia.io'; return { base, // Dev serves the admin UI at /; Vercel serves built app assets from /app/. @@ -124,12 +121,6 @@ function defineAppConfig({ command }: ConfigEnv): UserConfig { server: { hmr: process.env.APPGEN_DISABLE_HMR === '1' ? false : undefined, proxy: { - '/vertesia-api': { - target: devApiTarget, - changeOrigin: true, - secure: true, - rewrite: (path) => path.replace(/^\/vertesia-api/, ''), - }, '/__/auth': { target: 'https://dengenlabs.firebaseapp.com', changeOrigin: true, From 897efad35f418b5ca70273b115e587a6d7578a0b Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Tue, 12 May 2026 19:30:48 +0900 Subject: [PATCH 72/75] chore: update subproject commit reference in llumiverse --- llumiverse | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/llumiverse b/llumiverse index 211beea77..e1b816c0d 160000 --- a/llumiverse +++ b/llumiverse @@ -1 +1 @@ -Subproject commit 211beea779db480f845c5f5a2df1957d6b6e5f10 +Subproject commit e1b816c0d80fdcf5730cf4bfcb313a192ad8cb31 From 626a74e408b0e5ade1006dfa9ce9cd0ca32f1361 Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Tue, 12 May 2026 21:19:51 +0900 Subject: [PATCH 73/75] chore: update subproject commit reference in llumiverse --- llumiverse | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/llumiverse b/llumiverse index e1b816c0d..ebf1192e6 160000 --- a/llumiverse +++ b/llumiverse @@ -1 +1 @@ -Subproject commit e1b816c0d80fdcf5730cf4bfcb313a192ad8cb31 +Subproject commit ebf1192e68378fd6048d598deaca08aa511e5090 From 090151c3e80776fc32ba6353c7385d94ff7a5663 Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Wed, 13 May 2026 01:40:26 +0900 Subject: [PATCH 74/75] refactor: remove hardcoded known Git base URLs and enhance alias cleanup logic --- packages/cli/src/profiles/git.ts | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/profiles/git.ts b/packages/cli/src/profiles/git.ts index ccc21ade2..47c725440 100644 --- a/packages/cli/src/profiles/git.ts +++ b/packages/cli/src/profiles/git.ts @@ -15,14 +15,6 @@ interface GitCredentialInput { path?: string; } -const KNOWN_GIT_BASE_URLS = [ - 'https://git.vertesia.io', - 'https://git.us1.vertesia.io', - 'https://git.eu1.vertesia.io', - 'https://git.jp1.vertesia.io', - 'https://git.dev1.vertesia.io', -]; - export async function configureGitAuth(options: GitAuthOptions = {}) { const profile = getRequestedProfile(options.profile); const gitBaseUrl = normalizeGitBaseUrl(options.url || gitServerUrlForProfile(profile)); @@ -155,13 +147,26 @@ function gitConfig(...args: string[]) { } function removeKnownVertesiaAliases() { - for (const gitBaseUrl of KNOWN_GIT_BASE_URLS) { + let raw: string; + try { + raw = execFileSync('git', ['config', '--global', '--get-regexp', '^url\\..*\\.insteadOf$'], { + stdio: ['ignore', 'pipe', 'ignore'], + encoding: 'utf8', + }); + } catch { + // No matching keys, or no global config — nothing to clean up. + return; + } + for (const line of raw.split('\n')) { + const space = line.indexOf(' '); + if (space < 0) continue; + const key = line.slice(0, space); + const value = line.slice(space + 1); + if (value !== 'vertesia:') continue; try { - execFileSync('git', ['config', '--global', '--unset-all', `url.${gitBaseUrl}/.insteadOf`, 'vertesia:'], { - stdio: 'ignore', - }); + execFileSync('git', ['config', '--global', '--unset-all', key, '^vertesia:$'], { stdio: 'ignore' }); } catch { - // The alias may not exist yet. + // Best-effort; ignore if the key disappears between read and unset. } } } From 2e29cc820de4ca675bdf88bcd11e14afae7695f5 Mon Sep 17 00:00:00 2001 From: Eric Barroca Date: Wed, 13 May 2026 12:43:11 +0900 Subject: [PATCH 75/75] feat: enhance Git credential helper to handle missing profiles with descriptive errors --- packages/cli/src/index.ts | 7 +++++-- packages/cli/src/profiles/git.ts | 5 +++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 556c6226e..1a569c6c3 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -99,8 +99,11 @@ const authGit = authRoot.command("git") authGit.command("credential [action]") .description("Git credential helper entrypoint. Called by git; users should run `vertesia auth git` instead.") - .option("-p, --profile ", "Profile to use for credentials.") - .action((action: string | undefined, options: { profile?: string }) => serveGitCredential(action, options)); + .action(function (this: import("commander").Command, action: string | undefined) { + // --profile is declared on the parent `auth git`, so read via globals. + const profile = this.optsWithGlobals().profile as string | undefined; + return serveGitCredential(action, { profile }); + }); program.command("envs [envId]") .description("List the environments you have access to") diff --git a/packages/cli/src/profiles/git.ts b/packages/cli/src/profiles/git.ts index 47c725440..080affd88 100644 --- a/packages/cli/src/profiles/git.ts +++ b/packages/cli/src/profiles/git.ts @@ -46,6 +46,11 @@ export async function serveGitCredential(action: string | undefined, options: Pi const input = await readCredentialInput(); const profile = pickProfileForCredential(input, options.profile); if (!profile) { + if (options.profile) { + throw new Error( + `Vertesia profile '${options.profile}' was not found. Run \`vertesia auth git\` from an active profile.`, + ); + } throw new Error( `No Vertesia profile matches git host ${input.host || ''}. Run \`vertesia auth git\`.`, );