feat(agents): schema-mirror + install flow for template agents#369
Conversation
Adds managed_by_template_agent_id and source_template_org_id to entity_types, entity_relationship_types, event_classifiers, and watchers. When a user installs a template agent, the install flow mirrors that agent's canonical entity types, relationship types, classifiers and watcher definitions from the template org into the user's personal org. The two new columns record the provenance of those mirrored rows so they can be re-synced on template updates and treated as read-only by the user-org owner. Partial indexes only cover non-NULL rows so user-authored entity types/relationships/classifiers/watchers stay un-indexed (the common case).
New installAgentFromTemplate(templateAgentId, targetOrganizationId, userId) creates an agent row in the target org with template_agent_id set, then mirrors the template org's entity_types and entity_relationship_types into the target org tagged managed_by_template_agent_id + source_template_org_id. Core properties: - Idempotent. Re-running against the same target updates rather than creating duplicates. - Safe. The mirror never overwrites rows the user authored directly (managed_by_template_agent_id IS NULL). Slug collisions with user rows or with a different template agent abort with a descriptive error. - Re-syncable. resyncInstalledAgent() lets template schema evolution propagate to every installed instance. Watchers and classifiers are deferred to a follow-up — watchers need the watcher_versions + reaction_script dance, classifiers are scoped per- entity so they interact with entity mirroring. Paired with an integration test under __tests__/integration/agents/ that covers: create on first install, idempotency, managed_by tagging, re-sync propagation, refusal to install into the template's own org, refusal to overwrite user-authored rows.
Defense in depth: previously installAgentFromTemplate trusted the caller to have already authorised the user against the target org. The public install route does enforce that (it derives targetOrganizationId from the authenticated user's personal org), but any future caller could bypass it. Add an explicit membership lookup against the "member" table inside installAgentFromTemplate. Refuses when: - user has no member row in the target org - user is a plain member (only owner/admin can install) Adds two tests for the new error paths.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 32cd208913
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| UPDATE watcher_versions | ||
| SET version = ${row.version}, | ||
| name = ${row.name}, |
There was a problem hiding this comment.
Preserve watcher version history on resync
When a mirrored watcher already exists, this path updates the existing watcher_versions row in place instead of creating a new version row. Any historical windows that already reference that version_id will now resolve to the new prompt/schema, so past runs lose their original configuration and become semantically incorrect after a template resync. The mirror should append a new version and only move watchers.current_version_id.
Useful? React with 👍 / 👎.
| WHERE organization_id = ${targetOrgId} | ||
| AND slug = ${row.slug} | ||
| AND status = 'active' | ||
| LIMIT 1 |
There was a problem hiding this comment.
Detect slug collisions with archived watchers
The lookup only considers status = 'active', but watcher slugs are globally unique per org regardless of status (idx_watchers_org_slug is not filtered by status). If a target org already has an archived watcher with the same slug (for example from a previous install), this code falls into the insert path and then fails with a unique-constraint error instead of handling the collision deterministically.
Useful? React with 👍 / 👎.
| const existingAgentId = await findExistingInstall( | ||
| tx, | ||
| params.templateAgentId, | ||
| params.targetOrganizationId | ||
| ); |
There was a problem hiding this comment.
Make install upsert atomic for concurrent requests
Idempotency currently relies on a read-then-insert sequence (findExistingInstall followed by insert) without a uniqueness guard on (template_agent_id, organization_id). Two concurrent install calls can both observe no existing row and proceed, causing duplicate installs (or one request failing later on mirror-table unique conflicts) instead of consistently returning the same installed agent.
Useful? React with 👍 / 👎.
, #359 install-half) (#372) The install flow as built — schema-mirror clones a template's entity types / relationship types / classifiers / watchers into each user's personal org — was the wrong abstraction. Cross-org vocabulary (an entity in tenant org A referencing a type defined in a public-catalog org B by FK) is the planned direction; the mirror pipeline duplicated rows per user and added re-sync complexity for no working installs (verified 0 rows used the mirror columns in prod). Removed: - packages/owletto-backend/src/agents/install.ts (installAgentFromTemplate, resyncInstalledAgent) - packages/owletto-backend/src/agents/install-routes.ts (POST /api/install) - packages/owletto-backend/src/agents/install-manifest-routes.ts (GET /api/install/manifest/:slug) - All associated integration tests - subject-identities WhatsApp helpers (normalizePhoneE164, phoneToWhatsAppJid, linkWhatsAppToMember) + their unit tests - db/migrations/20260425120000_add_template_mirror_tracking.sql (rolled back on prod first) - Route registrations from src/index.ts Kept: - subject-identities.ts provisionMemberAndCoreIdentities — used by the signup hook in personal-org-provisioning.ts, orthogonal to install flow. - #352 personal-org-on-signup, #350/#354/#355/#356 personal-finance content — no install dependencies. DB state: prod migrated down via dbmate (mirror columns dropped), then 20260426120000_entities_entity_type_fk re-applied. 0 user-visible data lost.
Summary
20260425120000_add_template_mirror_tracking.sqladds nullable tracking columns to entity-schema tables (managed_by_template_agent_id,mirrored_at, etc.) so per-user installs can be linked back to the canonical template they came from.packages/owletto-backend/src/agents/install.tsimplementsinstallAgentFromTemplate: reads the canonical template, mirrors entity types / classifiers / watchers into the user's personal org, idempotent on re-install, errors loudly if a slug collides with user-authored content or a different agent's template.Motivation
Foundation for stacked PRs #357 (public install endpoint) → #359 (identity provisioning on install) → #362 (install manifest). Each user has a personal org (via #352), and template agents like
examples/personal-financeneed to mirror their schema into that org on first install.Test plan
packages/owletto-backend/src/__tests__/integration/agents/install.test.tscover the idempotent re-install path, slug-collision rejection, and the admin/owner membership check.make build-packagesclean.