From 23b053c354c274e5a4da8e432612059b1a55c906 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:37:16 +0500 Subject: [PATCH 1/5] feat(showcase): add object lifecycle hook examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The showcase had zero `hook` examples, so the Studio hook designer had nothing to render. Add three hooks under src/hooks that together exercise the hook surface and give Studio real metadata to display: • showcase_normalize_task_title — beforeInsert/beforeUpdate, L2 JS body that trims the task title (multi-event, mutate ctx.input). • showcase_audit_task_completion — afterUpdate gated by a CEL condition, async fire-and-forget with a retryPolicy, onError=log, capabilities=[log]. • showcase_warn_over_budget — afterUpdate on showcase_project, condition + async log. Wired via `defineStack({ hooks })`. Bodies read ctx.result/ctx.input (the real After-hook context shape — there is no ctx.record) and are guarded so a fresh seed runs them without throwing. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/app-showcase/objectstack.config.ts | 2 + examples/app-showcase/src/hooks/index.ts | 81 +++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 examples/app-showcase/src/hooks/index.ts diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index 1534e82b1e..089eba3961 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -21,6 +21,7 @@ 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'; @@ -152,6 +153,7 @@ export default defineStack({ flows: allFlows, jobs: allJobs, emailTemplates: allEmails, + hooks: allHooks, webhooks: allWebhooks, // Security diff --git a/examples/app-showcase/src/hooks/index.ts b/examples/app-showcase/src/hooks/index.ts new file mode 100644 index 0000000000..958065b9da --- /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 (ctx.log) 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 (ctx.log) 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, +]; From f7c464a0294d3f359ba77ad4cf4b557c61d48043 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:37:16 +0500 Subject: [PATCH 2/5] fix(spec): surface hook retryPolicy + timeout in the Studio designer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hook form's Execution section exposed async / onError / condition but not `retryPolicy` ({ maxRetries, backoffMs }) or the top-level `timeout`, both of which are real HookSchema properties — so the designer could not show or edit them. Add a `retryPolicy` composite and a `timeout` number to the Execution section so the hook designer covers the full schema. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/spec/src/data/hook.form.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) 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)' }, + ], + }, ], }, ], From 5e3a301dc82bb6bee345a6fca27942e13efdf703 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:37:16 +0500 Subject: [PATCH 3/5] chore(changeset): hook designer retryPolicy + timeout Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/hook-designer-retry-timeout.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/hook-designer-retry-timeout.md 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. From 5b96f5d4c8e371306e97acd0eb8ffb59cb05e5f1 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:53:53 +0500 Subject: [PATCH 4/5] fix(showcase): harden after-hook bodies against a non-callable ctx.log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A persisted-DB restart re-fired the afterUpdate hooks during rollup recompute and `ctx.log(...)` threw "not a function" — the `log` capability binding is not reliably callable in the sandbox. Guard with `typeof ctx.log === 'function'` so the audit / over-budget hooks are no-ops when logging is unavailable instead of throwing. Verified: fresh seed and warm restart both boot with 0 hook errors. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/app-showcase/src/hooks/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/app-showcase/src/hooks/index.ts b/examples/app-showcase/src/hooks/index.ts index 958065b9da..4e00716266 100644 --- a/examples/app-showcase/src/hooks/index.ts +++ b/examples/app-showcase/src/hooks/index.ts @@ -47,7 +47,7 @@ export const AuditTaskCompletionHook = { condition: "record.done == true", body: { language: 'js' as const, - source: "var r = ctx.result || ctx.input || {}; if (ctx.log) ctx.log('task completed: ' + (r.title || r.id || 'unknown'));", + 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, @@ -66,7 +66,7 @@ export const WarnOverBudgetHook = { condition: "record.spent > record.budget", body: { language: 'js' as const, - source: "var r = ctx.result || ctx.input || {}; if (ctx.log) ctx.log('project over budget: ' + (r.name || r.id || 'unknown') + ' (' + r.spent + ' / ' + r.budget + ')');", + 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, From 0774833388f93064d0b96c645e1c3730211ccfc7 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:53:53 +0500 Subject: [PATCH 5/5] feat(showcase): register AI tools (wire FindProjectTool + add object-bound tool) `FindProjectTool` was defined via defineTool but never imported into the config or passed to defineStack, so the Studio tool designer showed zero instances and the ProjectOps skill's `tools: ['showcase_find_project']` reference dangled. Wire the tools via `defineStack({ tools })` and add a second, object-bound tool (`showcase_summarize_project_tasks`, objectName + requiresConfirmation) so the tool designer covers both the plain and object-scoped shapes. The skill now references both. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/app-showcase/objectstack.config.ts | 3 ++- examples/app-showcase/src/agents/index.ts | 23 ++++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index 089eba3961..b1e27012ae 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -24,7 +24,7 @@ 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 { allRoles, allPermissionSets, @@ -165,6 +165,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];