diff --git a/.changeset/hook-designer-retry-timeout.md b/.changeset/hook-designer-retry-timeout.md new file mode 100644 index 0000000000..b2826487c7 --- /dev/null +++ b/.changeset/hook-designer-retry-timeout.md @@ -0,0 +1,5 @@ +--- +"@objectstack/spec": patch +--- + +fix(spec): surface hook `retryPolicy` and `timeout` in the Studio hook designer form (Execution section), completing schema coverage. diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index 68ec5f2846..e030922292 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -21,9 +21,10 @@ import { allActions } from './src/actions/index.js'; import { ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage } from './src/pages/index.js'; import { allFlows } from './src/flows/index.js'; import { allWebhooks } from './src/webhooks/index.js'; +import { allHooks } from './src/hooks/index.js'; import { allJobs } from './src/jobs/index.js'; import { allEmails } from './src/emails/index.js'; -import { ShowcaseAssistantAgent, ProjectOpsSkill } from './src/agents/index.js'; +import { ShowcaseAssistantAgent, ProjectOpsSkill, allTools } from './src/agents/index.js'; import { allBooks } from './src/books/index.js'; import { allRoles, @@ -154,6 +155,7 @@ export default defineStack({ flows: allFlows, jobs: allJobs, emailTemplates: allEmails, + hooks: allHooks, webhooks: allWebhooks, // Security @@ -165,6 +167,7 @@ export default defineStack({ // AI agents: [ShowcaseAssistantAgent], skills: [ProjectOpsSkill], + tools: allTools, // Seed data data: ShowcaseSeedData, diff --git a/examples/app-showcase/src/agents/index.ts b/examples/app-showcase/src/agents/index.ts index 6827ea98e1..836980111c 100644 --- a/examples/app-showcase/src/agents/index.ts +++ b/examples/app-showcase/src/agents/index.ts @@ -17,12 +17,31 @@ export const FindProjectTool = defineTool({ builtIn: false, }); +/** Tool — object-bound summary that needs confirmation before it runs. */ +export const SummarizeProjectTasksTool = defineTool({ + name: 'showcase_summarize_project_tasks', + label: 'Summarize Project Tasks', + description: 'Summarise the open tasks for a project, grouped by status.', + objectName: 'showcase_task', + parameters: { + type: 'object', + properties: { + project_id: { type: 'string', description: 'Project record id' }, + include_done: { type: 'boolean', description: 'Include completed tasks', default: false }, + }, + required: ['project_id'], + }, + requiresConfirmation: true, + active: true, + builtIn: false, +}); + /** Skill — bundles the project tools. */ export const ProjectOpsSkill = defineSkill({ name: 'showcase_project_ops', label: 'Project Operations', description: 'Tools and prompts for working with projects and tasks.', - tools: ['showcase_find_project'], + tools: ['showcase_find_project', 'showcase_summarize_project_tasks'], active: true, }); @@ -36,3 +55,5 @@ export const ShowcaseAssistantAgent = defineAgent({ active: true, visibility: 'global', }); + +export const allTools = [FindProjectTool, SummarizeProjectTasksTool]; diff --git a/examples/app-showcase/src/hooks/index.ts b/examples/app-showcase/src/hooks/index.ts new file mode 100644 index 0000000000..4e00716266 --- /dev/null +++ b/examples/app-showcase/src/hooks/index.ts @@ -0,0 +1,81 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Object lifecycle hooks — the showcase's "logic layer". + * + * Each hook is a plain object validated by `HookSchema` inside + * `defineStack({ hooks })` (same authoring style as webhooks). Together they + * exercise the full hook designer surface so Studio has something real to + * render for every property: + * + * • multi-event targeting (`beforeInsert` + `beforeUpdate`) + * • an L2 sandboxed-JS `body` (language + source + capabilities) + * • a CEL `condition` gate + * • fire-and-forget `async` execution with a `retryPolicy` + * • `onError` / `priority` tuning across more than one object + * + * The bodies are deliberately tiny and side-effect-light — they are read as + * documentation as much as they run. + */ + +type LifecycleEvent = + | 'beforeInsert' | 'afterInsert' + | 'beforeUpdate' | 'afterUpdate' + | 'beforeDelete' | 'afterDelete'; + +/** beforeInsert/beforeUpdate — normalise the task title before it is stored. */ +export const NormalizeTaskTitleHook = { + name: 'showcase_normalize_task_title', + label: 'Normalize Task Title', + object: 'showcase_task', + events: ['beforeInsert', 'beforeUpdate'] as LifecycleEvent[], + body: { + language: 'js' as const, + source: "if (ctx.input.title) ctx.input.title = ctx.input.title.trim();", + }, + priority: 50, + onError: 'abort' as const, + description: 'Trims leading/trailing whitespace from the task title before every write.', +}; + +/** afterUpdate (gated) — log a line whenever a task flips to done. */ +export const AuditTaskCompletionHook = { + name: 'showcase_audit_task_completion', + label: 'Audit Task Completion', + object: 'showcase_task', + events: ['afterUpdate'] as LifecycleEvent[], + condition: "record.done == true", + body: { + language: 'js' as const, + source: "var r = ctx.result || ctx.input || {}; if (typeof ctx.log === 'function') ctx.log('task completed: ' + (r.title || r.id || 'unknown'));", + capabilities: ['log'] as ('log')[], + }, + async: true, + priority: 90, + retryPolicy: { maxRetries: 3, backoffMs: 1000 }, + onError: 'log' as const, + description: 'Fire-and-forget audit line emitted after a task transitions to done.', +}; + +/** afterUpdate (gated) — warn when a project goes over budget. */ +export const WarnOverBudgetHook = { + name: 'showcase_warn_over_budget', + label: 'Warn On Over-Budget Project', + object: 'showcase_project', + events: ['afterUpdate'] as LifecycleEvent[], + condition: "record.spent > record.budget", + body: { + language: 'js' as const, + source: "var r = ctx.result || ctx.input || {}; if (typeof ctx.log === 'function') ctx.log('project over budget: ' + (r.name || r.id || 'unknown') + ' (' + r.spent + ' / ' + r.budget + ')');", + capabilities: ['log'] as ('log')[], + }, + async: true, + onError: 'log' as const, + description: 'Emits a warning when a project’s spend exceeds its budget.', +}; + +export const allHooks = [ + NormalizeTaskTitleHook, + AuditTaskCompletionHook, + WarnOverBudgetHook, +]; diff --git a/packages/spec/src/data/hook.form.ts b/packages/spec/src/data/hook.form.ts index 25fc57a3dd..c0bf4ad467 100644 --- a/packages/spec/src/data/hook.form.ts +++ b/packages/spec/src/data/hook.form.ts @@ -67,7 +67,18 @@ export const hookForm = defineForm({ { label: 'Abort', value: 'abort' }, { label: 'Log', value: 'log' }, ] }, + { field: 'timeout', type: 'number', colSpan: 1, helpText: 'Abort the hook after N milliseconds' }, { field: 'condition', type: 'code', language: 'javascript', colSpan: 2, helpText: 'Optional formula — skip the hook when this evaluates to false' }, + { + field: 'retryPolicy', + type: 'composite', + colSpan: 2, + helpText: 'Retry on failure — most useful for async hooks', + fields: [ + { field: 'maxRetries', type: 'number', helpText: 'Maximum retry attempts' }, + { field: 'backoffMs', type: 'number', helpText: 'Delay between retries (ms)' }, + ], + }, ], }, ],