diff --git a/db/migrations/20260517150000_goals_primitive.sql b/db/migrations/20260517150000_goals_primitive.sql new file mode 100644 index 000000000..48c17e4ce --- /dev/null +++ b/db/migrations/20260517150000_goals_primitive.sql @@ -0,0 +1,55 @@ +-- migrate:up + +-- Goals primitive — a top-level handle that groups watchers under a single +-- user-facing intent (e.g. "Keep my CRM clean", "Watch competitors"). Each +-- watcher may optionally point at a goal; goals are the surface the +-- canvas/UI (#801) hangs off, while watchers stay the executable unit. +-- +-- Schema notes: +-- - organization_id is `text` (the better-auth `organization.id`) to match +-- the rest of the schema; the issue body called it `integer`, but every +-- other org-scoped table (watchers, feeds, connections, …) uses text. +-- - `(organization_id, slug)` is unique so `lobu apply` can upsert by slug +-- the same way it does for watchers/agents. +-- - Goal-template loading from YAML is out of scope here; `template_key` +-- is just a free-form pointer for the future loader to claim. +-- - `metadata` is jsonb for forward-compat (icon, color, owner, etc.) +-- without another migration each time the UI grows a knob. + +CREATE TABLE public.goals ( + id bigserial PRIMARY KEY, + organization_id text NOT NULL REFERENCES public.organization(id) ON DELETE CASCADE, + slug text NOT NULL, + name text NOT NULL, + description text, + status text NOT NULL DEFAULT 'active', + template_key text, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + + CONSTRAINT goals_status_check + CHECK (status IN ('active', 'paused', 'archived')), + CONSTRAINT goals_org_slug_unique + UNIQUE (organization_id, slug) +); + +CREATE INDEX idx_goals_organization_id + ON public.goals (organization_id); + +-- Watcher → goal link. NULL means "ungrouped" (today's behavior). ON DELETE +-- SET NULL keeps the watcher alive when its goal is archived/deleted; the +-- watcher just becomes ungrouped. +ALTER TABLE public.watchers + ADD COLUMN goal_id bigint REFERENCES public.goals(id) ON DELETE SET NULL; + +CREATE INDEX idx_watchers_goal_id + ON public.watchers (goal_id) + WHERE goal_id IS NOT NULL; + +-- migrate:down + +ALTER TABLE public.watchers + DROP COLUMN IF EXISTS goal_id; + +DROP TABLE IF EXISTS public.goals; diff --git a/db/schema.sql b/db/schema.sql index 88fe03247..41cdfd800 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1103,6 +1103,41 @@ CREATE SEQUENCE public.feeds_id_seq ALTER SEQUENCE public.feeds_id_seq OWNED BY public.feeds.id; +-- +-- Name: goals; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.goals ( + id bigint NOT NULL, + organization_id text NOT NULL, + slug text NOT NULL, + name text NOT NULL, + description text, + status text DEFAULT 'active'::text NOT NULL, + template_key text, + metadata jsonb DEFAULT '{}'::jsonb NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT goals_status_check CHECK ((status = ANY (ARRAY['active'::text, 'paused'::text, 'archived'::text]))) +); + +-- +-- Name: goals_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.goals_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +-- +-- Name: goals_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.goals_id_seq OWNED BY public.goals.id; + -- -- Name: grants; Type: TABLE; Schema: public; Owner: - -- @@ -2004,6 +2039,7 @@ CREATE TABLE public.watchers ( scheduler_client_id text, source_watcher_id integer, watcher_group_id integer NOT NULL, + goal_id bigint, device_worker_id uuid, agent_kind text, notification_channel text DEFAULT 'canvas'::text NOT NULL, @@ -2153,6 +2189,12 @@ ALTER TABLE ONLY public.events ALTER COLUMN id SET DEFAULT nextval('public.conte ALTER TABLE ONLY public.feeds ALTER COLUMN id SET DEFAULT nextval('public.feeds_id_seq'::regclass); +-- +-- Name: goals id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.goals ALTER COLUMN id SET DEFAULT nextval('public.goals_id_seq'::regclass); + -- -- Name: personal_access_tokens id; Type: DEFAULT; Schema: public; Owner: - -- @@ -2437,6 +2479,20 @@ ALTER TABLE ONLY public.events ALTER TABLE ONLY public.feeds ADD CONSTRAINT feeds_pkey PRIMARY KEY (id); +-- +-- Name: goals goals_org_slug_unique; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.goals + ADD CONSTRAINT goals_org_slug_unique UNIQUE (organization_id, slug); + +-- +-- Name: goals goals_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.goals + ADD CONSTRAINT goals_pkey PRIMARY KEY (id); + -- -- Name: grants grants_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -3571,6 +3627,12 @@ CREATE INDEX idx_feeds_org ON public.feeds USING btree (organization_id); CREATE INDEX idx_feeds_status ON public.feeds USING btree (status); +-- +-- Name: idx_goals_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_goals_organization_id ON public.goals USING btree (organization_id); + -- -- Name: idx_latest_ec_classifier_id; Type: INDEX; Schema: public; Owner: - -- @@ -3823,6 +3885,12 @@ CREATE INDEX idx_watchers_device_worker_id ON public.watchers USING btree (devic CREATE INDEX idx_watchers_entity_ids ON public.watchers USING gin (entity_ids); +-- +-- Name: idx_watchers_goal_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_watchers_goal_id ON public.watchers USING btree (goal_id) WHERE (goal_id IS NOT NULL); + -- -- Name: idx_watchers_next_run_at; Type: INDEX; Schema: public; Owner: - -- @@ -4565,6 +4633,13 @@ ALTER TABLE ONLY public.event_classifiers ALTER TABLE ONLY public.event_classifiers ADD CONSTRAINT fk_event_classifiers_insight FOREIGN KEY (watcher_id) REFERENCES public.watchers(id) ON DELETE SET NULL; +-- +-- Name: goals goals_organization_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.goals + ADD CONSTRAINT goals_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES public.organization(id) ON DELETE CASCADE; + -- -- Name: grants grants_org_agent_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -4943,6 +5018,13 @@ ALTER TABLE ONLY public.watchers ALTER TABLE ONLY public.watchers ADD CONSTRAINT watchers_device_worker_id_fkey FOREIGN KEY (device_worker_id) REFERENCES public.device_workers(id); +-- +-- Name: watchers watchers_goal_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.watchers + ADD CONSTRAINT watchers_goal_id_fkey FOREIGN KEY (goal_id) REFERENCES public.goals(id) ON DELETE SET NULL; + -- -- Name: watchers watchers_organization_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -5028,4 +5110,5 @@ INSERT INTO public.schema_migrations (version) VALUES ('20260517030000'), ('20260517040000'), ('20260517050000'), - ('20260517060000'); + ('20260517060000'), + ('20260517150000'); diff --git a/packages/server/src/__tests__/integration/watchers/goals-crud.test.ts b/packages/server/src/__tests__/integration/watchers/goals-crud.test.ts new file mode 100644 index 000000000..4e2fadd1f --- /dev/null +++ b/packages/server/src/__tests__/integration/watchers/goals-crud.test.ts @@ -0,0 +1,257 @@ +/** + * manage_goals CRUD via the SDK surface. + * + * Covers create / get / update / list / archive / delete and the cross-org + * isolation paths. Also asserts the watchers.goal_id FK behavior: + * - watchers may link to a goal at create or update time; + * - deleting a goal nulls out the watcher's `goal_id` (ON DELETE SET NULL) + * rather than cascading; + * - deleting the parent org cascades the goal away cleanly. + */ + +import { beforeAll, describe, expect, it } from 'vitest'; +import { + addUserToOrganization, + createTestAgent, + createTestOrganization, + createTestUser, +} from '../../setup/test-fixtures'; +import { TestApiClient } from '../../setup/test-mcp-client'; +import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; + +describe('goals CRUD', () => { + let owner: TestApiClient; + let intruder: TestApiClient; + let agentId: string; + let organizationId: string; + let intruderOrgId: string; + + beforeAll(async () => { + await cleanupTestDatabase(); + const org = await createTestOrganization({ name: 'Goals Test Org' }); + organizationId = org.id; + const user = await createTestUser({ email: 'goals-owner@test.com' }); + await addUserToOrganization(user.id, org.id, 'owner'); + owner = await TestApiClient.for({ + organizationId: org.id, + userId: user.id, + memberRole: 'owner', + }); + const agent = await createTestAgent({ organizationId: org.id, ownerUserId: user.id }); + agentId = agent.agentId; + + const otherOrg = await createTestOrganization({ name: 'Goals Other Org' }); + intruderOrgId = otherOrg.id; + const otherUser = await createTestUser({ email: 'goals-other@test.com' }); + await addUserToOrganization(otherUser.id, otherOrg.id, 'owner'); + intruder = await TestApiClient.for({ + organizationId: otherOrg.id, + userId: otherUser.id, + memberRole: 'owner', + }); + }); + + it('creates → reads back → updates → archives → deletes a goal', async () => { + const created = (await owner.goals.create({ + slug: 'crm-hygiene', + name: 'Keep CRM clean', + description: 'Daily checks on stale leads.', + template_key: 'templates/crm-hygiene', + metadata: { color: 'emerald' }, + })) as { action: 'create'; goal: { id: number; slug: string; status: string } }; + expect(created.goal.id).toBeDefined(); + expect(created.goal.slug).toBe('crm-hygiene'); + expect(created.goal.status).toBe('active'); + + const got = (await owner.goals.get({ goal_id: created.goal.id })) as { + goal: { name: string; metadata: Record }; + }; + expect(got.goal.name).toBe('Keep CRM clean'); + expect(got.goal.metadata).toEqual({ color: 'emerald' }); + + // Merge semantics: passing metadata without replace_metadata merges. + await owner.goals.update({ + goal_id: created.goal.id, + name: 'Keep CRM tidy', + metadata: { icon: 'broom' }, + }); + const afterUpdate = (await owner.goals.get({ slug: 'crm-hygiene' })) as { + goal: { name: string; metadata: Record }; + }; + expect(afterUpdate.goal.name).toBe('Keep CRM tidy'); + expect(afterUpdate.goal.metadata).toEqual({ color: 'emerald', icon: 'broom' }); + + // Replace semantics: replace_metadata wipes the prior keys. + await owner.goals.update({ + goal_id: created.goal.id, + metadata: { color: 'red' }, + replace_metadata: true, + }); + const afterReplace = (await owner.goals.get({ goal_id: created.goal.id })) as { + goal: { metadata: Record }; + }; + expect(afterReplace.goal.metadata).toEqual({ color: 'red' }); + + const archived = (await owner.goals.archive({ goal_id: created.goal.id })) as { + goal: { status: string }; + }; + expect(archived.goal.status).toBe('archived'); + + const deleted = (await owner.goals.delete({ goal_id: created.goal.id })) as { + deleted: true; + }; + expect(deleted.deleted).toBe(true); + + await expect(owner.goals.get({ goal_id: created.goal.id })).rejects.toThrow(/not found/i); + }); + + it('lists goals scoped to the caller org and filters by status', async () => { + const a = (await owner.goals.create({ slug: 'active-1', name: 'Active 1' })) as { + goal: { id: number }; + }; + const b = (await owner.goals.create({ + slug: 'paused-1', + name: 'Paused 1', + status: 'paused', + })) as { goal: { id: number } }; + + const allMine = (await owner.goals.list()) as { + goals: Array<{ id: number; status: string }>; + }; + const ids = allMine.goals.map((g) => g.id); + expect(ids).toContain(a.goal.id); + expect(ids).toContain(b.goal.id); + + const onlyPaused = (await owner.goals.list({ status: 'paused' })) as { + goals: Array<{ id: number; status: string }>; + }; + expect(onlyPaused.goals.every((g) => g.status === 'paused')).toBe(true); + expect(onlyPaused.goals.some((g) => g.id === b.goal.id)).toBe(true); + + // Cleanup + await owner.goals.delete({ goal_id: a.goal.id }); + await owner.goals.delete({ goal_id: b.goal.id }); + }); + + it('blocks cross-org reads and writes', async () => { + const created = (await owner.goals.create({ + slug: 'xorg-goal', + name: 'Owner Goal', + })) as { goal: { id: number; slug: string } }; + + await expect(intruder.goals.get({ goal_id: created.goal.id })).rejects.toThrow(/not found/i); + await expect( + intruder.goals.update({ goal_id: created.goal.id, name: 'hijack' }) + ).rejects.toThrow(/not found/i); + await expect(intruder.goals.delete({ goal_id: created.goal.id })).rejects.toThrow(/not found/i); + + await owner.goals.delete({ goal_id: created.goal.id }); + }); + + it('rejects an invalid slug', async () => { + await expect( + owner.goals.create({ slug: 'BAD SLUG!', name: 'Invalid' }) + ).rejects.toThrow(/Invalid goal slug/); + }); + + it('rejects a duplicate slug in the same org via the UNIQUE constraint', async () => { + const first = (await owner.goals.create({ slug: 'dup-slug', name: 'First' })) as { + goal: { id: number }; + }; + await expect( + owner.goals.create({ slug: 'dup-slug', name: 'Second' }) + ).rejects.toThrow(/duplicate|unique|already/i); + await owner.goals.delete({ goal_id: first.goal.id }); + }); + + it('links a watcher to a goal, nulls goal_id on goal delete, and rejects cross-org goal_id', async () => { + const goal = (await owner.goals.create({ slug: 'link-test', name: 'Linker' })) as { + goal: { id: number }; + }; + + // Create-time link + const created = (await owner.watchers.create({ + slug: 'goal-linked-watcher', + name: 'Goal Linked Watcher', + prompt: 'Track items.', + extraction_schema: { type: 'object', properties: {} }, + agent_id: agentId, + goal_id: goal.goal.id, + })) as { watcher_id: string }; + + const got = (await owner.watchers.get(created.watcher_id)) as { + watcher?: { goal_id: number | null }; + }; + expect(got.watcher?.goal_id).toBe(goal.goal.id); + + // Update-time unlink + await owner.watchers.update({ watcher_id: created.watcher_id, goal_id: null }); + const afterUnlink = (await owner.watchers.get(created.watcher_id)) as { + watcher?: { goal_id: number | null }; + }; + expect(afterUnlink.watcher?.goal_id).toBeNull(); + + // Re-link, then delete the goal — watcher.goal_id must become NULL + // (ON DELETE SET NULL), not delete the watcher. + await owner.watchers.update({ watcher_id: created.watcher_id, goal_id: goal.goal.id }); + await owner.goals.delete({ goal_id: goal.goal.id }); + + const afterGoalDelete = (await owner.watchers.get(created.watcher_id)) as { + watcher?: { goal_id: number | null }; + }; + expect(afterGoalDelete.watcher?.goal_id).toBeNull(); + + // Cross-org goal_id is rejected (the FK would accept any goal id; the + // handler validates org scope). + const otherGoal = (await intruder.goals.create({ + slug: 'other-org-goal', + name: 'Other Goal', + })) as { goal: { id: number } }; + + await expect( + owner.watchers.update({ + watcher_id: created.watcher_id, + goal_id: otherGoal.goal.id, + }) + ).rejects.toThrow(/not found in this organization/i); + + await owner.watchers.delete([created.watcher_id]); + await intruder.goals.delete({ goal_id: otherGoal.goal.id }); + }); + + it('cascades the goal away when its organization is deleted', async () => { + const tmpOrg = await createTestOrganization({ name: 'Goals Tmp Org' }); + const tmpUser = await createTestUser({ email: 'goals-tmp@test.com' }); + await addUserToOrganization(tmpUser.id, tmpOrg.id, 'owner'); + const tmpClient = await TestApiClient.for({ + organizationId: tmpOrg.id, + userId: tmpUser.id, + memberRole: 'owner', + }); + + const tmpGoal = (await tmpClient.goals.create({ + slug: 'tmp-goal', + name: 'Will be cascaded', + })) as { goal: { id: number } }; + + const sql = getTestDb(); + // Drop the org row directly (better-auth Org deletes go through HTTP — we + // just want to assert the FK cascade landed). + await sql.unsafe(`DELETE FROM "organization" WHERE id = $1`, [tmpOrg.id]); + + const remaining = (await sql.unsafe( + `SELECT id FROM goals WHERE id = $1`, + [tmpGoal.goal.id] + )) as Array<{ id: number }>; + expect(remaining.length).toBe(0); + }); + + it('exposes manage_goals on the REST tool surface (smoke check)', async () => { + // Sanity: the namespaces below are wired in client-sdk + REST. This just + // confirms that the SDK + handler are mounted under client.goals. + expect(typeof owner.goals.create).toBe('function'); + expect(typeof owner.goals.list).toBe('function'); + expect(organizationId).toBeTruthy(); + expect(intruderOrgId).toBeTruthy(); + }); +}); diff --git a/packages/server/src/__tests__/setup/test-mcp-client.ts b/packages/server/src/__tests__/setup/test-mcp-client.ts index 296d53044..f91524b46 100644 --- a/packages/server/src/__tests__/setup/test-mcp-client.ts +++ b/packages/server/src/__tests__/setup/test-mcp-client.ts @@ -32,6 +32,7 @@ import { buildEntitiesNamespace, buildEntitySchemaNamespace, buildFeedsNamespace, + buildGoalsNamespace, buildKnowledgeNamespace, buildOperationsNamespace, buildOrganizationsNamespace, @@ -187,6 +188,7 @@ export class TestApiClient { readonly entities: ReturnType; readonly entity_schema: ReturnType; readonly feeds: ReturnType; + readonly goals: ReturnType; readonly knowledge: ReturnType; readonly operations: ReturnType; readonly organizations: ReturnType; @@ -203,6 +205,7 @@ export class TestApiClient { this.entities = buildEntitiesNamespace(ctx, env); this.entity_schema = buildEntitySchemaNamespace(ctx, env); this.feeds = buildFeedsNamespace(ctx, env); + this.goals = buildGoalsNamespace(ctx, env); this.knowledge = buildKnowledgeNamespace(ctx, env); this.operations = buildOperationsNamespace(ctx, env); this.organizations = buildOrganizationsNamespace(ctx); diff --git a/packages/server/src/auth/tool-access.ts b/packages/server/src/auth/tool-access.ts index f9e0b3306..47f1d167b 100644 --- a/packages/server/src/auth/tool-access.ts +++ b/packages/server/src/auth/tool-access.ts @@ -89,6 +89,7 @@ const OWNER_ADMIN_ACTIONS: Record> = { 'classify', ]), manage_view_templates: new Set(['set', 'rollback', 'remove_tab']), + manage_goals: new Set(['create', 'update', 'archive', 'delete']), }; const PUBLIC_READ_ACTIONS: Record | null> = { @@ -115,6 +116,7 @@ const PUBLIC_READ_ACTIONS: Record | null> = { ]), manage_classifiers: new Set(['list', 'get_versions']), manage_view_templates: new Set(['get']), + manage_goals: new Set(['get', 'list']), }; function getAction(args: unknown): string | null { diff --git a/packages/server/src/db/embedded-schema-patches.ts b/packages/server/src/db/embedded-schema-patches.ts index 6b51470cd..adfc32547 100644 --- a/packages/server/src/db/embedded-schema-patches.ts +++ b/packages/server/src/db/embedded-schema-patches.ts @@ -846,4 +846,82 @@ export const EMBEDDED_SCHEMA_PATCHES: EmbeddedSchemaPatch[] = [ `); }, }, + { + // Mirrors db/migrations/20260517150000_goals_primitive.sql. + // + // Creates the `goals` table (top-level handle that groups watchers under + // a single user intent) and adds `watchers.goal_id` as a nullable FK + // with ON DELETE SET NULL. Idempotent — all DDL uses IF NOT EXISTS, and + // the watcher column is only added when missing. + // + // Sequenced after `watcher-schema-additions` (#799); the only watcher-side + // change here is the new column, which composes cleanly with the columns + // added by that patch. + id: 'goals-primitive', + apply: async (sql) => { + await sql.unsafe(` + CREATE TABLE IF NOT EXISTS public.goals ( + id bigserial PRIMARY KEY, + organization_id text NOT NULL REFERENCES public.organization(id) ON DELETE CASCADE, + slug text NOT NULL, + name text NOT NULL, + description text, + status text NOT NULL DEFAULT 'active', + template_key text, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now() + ) + `); + await sql.unsafe(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'goals_status_check' + ) THEN + ALTER TABLE public.goals + ADD CONSTRAINT goals_status_check + CHECK (status IN ('active', 'paused', 'archived')); + END IF; + END$$; + `); + await sql.unsafe(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'goals_org_slug_unique' + ) THEN + ALTER TABLE public.goals + ADD CONSTRAINT goals_org_slug_unique UNIQUE (organization_id, slug); + END IF; + END$$; + `); + await sql.unsafe(` + CREATE INDEX IF NOT EXISTS idx_goals_organization_id + ON public.goals (organization_id) + `); + + await sql.unsafe(` + ALTER TABLE public.watchers + ADD COLUMN IF NOT EXISTS goal_id bigint + `); + await sql.unsafe(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'watchers_goal_id_fkey' + ) THEN + ALTER TABLE public.watchers + ADD CONSTRAINT watchers_goal_id_fkey + FOREIGN KEY (goal_id) REFERENCES public.goals(id) ON DELETE SET NULL; + END IF; + END$$; + `); + await sql.unsafe(` + CREATE INDEX IF NOT EXISTS idx_watchers_goal_id + ON public.watchers (goal_id) + WHERE goal_id IS NOT NULL + `); + }, + }, ]; diff --git a/packages/server/src/sandbox/client-sdk.ts b/packages/server/src/sandbox/client-sdk.ts index bb344cf8a..b1731e250 100644 --- a/packages/server/src/sandbox/client-sdk.ts +++ b/packages/server/src/sandbox/client-sdk.ts @@ -24,6 +24,7 @@ import { buildEntitiesNamespace, buildEntitySchemaNamespace, buildFeedsNamespace, + buildGoalsNamespace, buildKnowledgeNamespace, buildOperationsNamespace, buildOrganizationsNamespace, @@ -36,6 +37,7 @@ import type { ConnectionsNamespace } from "./namespaces/connections"; import type { EntitiesNamespace } from "./namespaces/entities"; import type { EntitySchemaNamespace } from "./namespaces/entity-schema"; import type { FeedsNamespace } from "./namespaces/feeds"; +import type { GoalsNamespace } from "./namespaces/goals"; import type { KnowledgeNamespace } from "./namespaces/knowledge"; import type { OperationsNamespace } from "./namespaces/operations"; import type { OrganizationsNamespace } from "./namespaces/organizations"; @@ -50,6 +52,7 @@ export interface ClientSDK { authProfiles: AuthProfilesNamespace; operations: OperationsNamespace; watchers: WatchersNamespace; + goals: GoalsNamespace; classifiers: ClassifiersNamespace; viewTemplates: ViewTemplatesNamespace; knowledge: KnowledgeNamespace; @@ -165,6 +168,7 @@ export function buildClientSDK( authProfiles: buildAuthProfilesNamespace(ctx, env), operations: buildOperationsNamespace(ctx, env), watchers: buildWatchersNamespace(ctx, env), + goals: buildGoalsNamespace(ctx, env), classifiers: buildClassifiersNamespace(ctx, env), viewTemplates: buildViewTemplatesNamespace(ctx, env), knowledge: buildKnowledgeNamespace(ctx, env), diff --git a/packages/server/src/sandbox/method-metadata.ts b/packages/server/src/sandbox/method-metadata.ts index 3ed3f70f6..d505ff6a1 100644 --- a/packages/server/src/sandbox/method-metadata.ts +++ b/packages/server/src/sandbox/method-metadata.ts @@ -507,6 +507,36 @@ export default async (_ctx, client) => { access: "write", }, + // goals — top-level handles that group watchers under a single intent. + "goals.manage": { + summary: "Raw manage_goals action wrapper. Prefer named methods.", + access: "write", + }, + "goals.create": { + summary: "Create a goal (slug + name; optional description / status / template_key / metadata).", + access: "write", + }, + "goals.update": { + summary: "Update a goal's name / description / status / template_key / metadata.", + access: "write", + }, + "goals.get": { + summary: "Fetch a single goal by id or slug.", + access: "read", + }, + "goals.list": { + summary: "List goals in the current organization (optional status filter).", + access: "read", + }, + "goals.archive": { + summary: "Set a goal's status to 'archived'. Linked watchers keep running.", + access: "write", + }, + "goals.delete": { + summary: "Delete a goal row. Linked watchers survive via ON DELETE SET NULL.", + access: "write", + }, + // top-level query: { summary: diff --git a/packages/server/src/sandbox/namespaces/goals.ts b/packages/server/src/sandbox/namespaces/goals.ts new file mode 100644 index 000000000..bc8264847 --- /dev/null +++ b/packages/server/src/sandbox/namespaces/goals.ts @@ -0,0 +1,71 @@ +/** + * ClientSDK `goals` namespace. Thin wrapper over `manageGoals`. + * + * Goals are top-level handles that group watchers under a single user-facing + * intent (e.g. "Keep my CRM clean"). See `manage_goals.ts` for the action + * surface. + */ + +import type { Env } from "../../index"; +import { manageGoals } from "../../tools/admin/manage_goals"; +import type { ToolContext } from "../../tools/registry"; +import { createActionCaller } from "./action-call"; + +type GoalId = number; +type GoalStatus = "active" | "paused" | "archived"; + +export interface GoalCreateInput { + slug: string; + name: string; + description?: string; + status?: GoalStatus; + template_key?: string; + metadata?: Record; +} + +export interface GoalUpdateInput { + goal_id?: GoalId; + slug?: string; + name?: string; + description?: string | null; + status?: GoalStatus; + template_key?: string | null; + metadata?: Record; + replace_metadata?: boolean; +} + +export interface GoalsListFilter { + status?: GoalStatus; + limit?: number; + offset?: number; +} + +export interface GoalLookup { + goal_id?: GoalId; + slug?: string; +} + +export interface GoalsNamespace { + /** Raw escape hatch for any manage_goals action. */ + manage(input: Record): Promise; + create(input: GoalCreateInput): Promise; + update(input: GoalUpdateInput): Promise; + get(input: GoalLookup): Promise; + list(filter?: GoalsListFilter): Promise; + archive(input: GoalLookup): Promise; + delete(input: GoalLookup): Promise; +} + +export function buildGoalsNamespace(ctx: ToolContext, env: Env): GoalsNamespace { + const { manage, action } = createActionCaller(manageGoals, env, ctx); + + return { + manage, + create: (input) => action("create", input), + update: (input) => action("update", input), + get: (input) => action("get", input), + list: (filter) => action("list", filter ?? {}), + archive: (input) => action("archive", input), + delete: (input) => action("delete", input), + }; +} diff --git a/packages/server/src/sandbox/namespaces/index.ts b/packages/server/src/sandbox/namespaces/index.ts index d0ee1fb9c..b614ff967 100644 --- a/packages/server/src/sandbox/namespaces/index.ts +++ b/packages/server/src/sandbox/namespaces/index.ts @@ -8,6 +8,7 @@ export { buildConnectionsNamespace } from "./connections"; export { buildEntitiesNamespace } from "./entities"; export { buildEntitySchemaNamespace } from "./entity-schema"; export { buildFeedsNamespace } from "./feeds"; +export { buildGoalsNamespace } from "./goals"; export { buildKnowledgeNamespace } from "./knowledge"; export { buildOperationsNamespace } from "./operations"; export { buildOrganizationsNamespace } from "./organizations"; diff --git a/packages/server/src/sandbox/namespaces/watchers.ts b/packages/server/src/sandbox/namespaces/watchers.ts index 46cfb3e0f..80cc61cd6 100644 --- a/packages/server/src/sandbox/namespaces/watchers.ts +++ b/packages/server/src/sandbox/namespaces/watchers.ts @@ -47,6 +47,8 @@ export interface WatcherCreateInput { condensation_window_count?: number; reactions_guidance?: string; agent_id?: string; + /** Optional goal_id linking this watcher to a goal (see client.goals). */ + goal_id?: number | null; scheduler_client_id?: string; model_config?: Record; tags?: string[]; @@ -56,6 +58,8 @@ export interface WatcherUpdateInput { watcher_id: WatcherId; schedule?: string; agent_id?: string; + /** Optional goal_id linking this watcher to a goal. Pass null to unlink. */ + goal_id?: number | null; scheduler_client_id?: string; model_config?: Record; sources?: Source[]; diff --git a/packages/server/src/tools/admin/index.ts b/packages/server/src/tools/admin/index.ts index 2eb489caf..1fb16e5b9 100644 --- a/packages/server/src/tools/admin/index.ts +++ b/packages/server/src/tools/admin/index.ts @@ -21,6 +21,7 @@ import { ManageConnectionsSchema, manageConnections } from './manage_connections import { ManageEntitySchema, manageEntity } from './manage_entity'; import { ManageEntitySchemaSchema, manageEntitySchema } from './manage_entity_schema'; import { ManageFeedsSchema, manageFeeds } from './manage_feeds'; +import { ManageGoalsSchema, manageGoals } from './manage_goals'; import { NotifySchema, notify } from './notify'; import { ManageOperationsSchema, manageOperations } from './manage_operations'; import { ManageSchedulesSchema, manageSchedules } from './manage_schedules'; @@ -108,6 +109,13 @@ const ENTRIES: InternalToolEntry[] = [ schema: ManageWatchersSchema, handler: manageWatchers, }, + { + name: 'manage_goals', + description: + 'Goal primitive management (top-level handle that groups watchers). SDK alternative: client.goals.', + schema: ManageGoalsSchema, + handler: manageGoals, + }, { name: 'list_watchers', description: 'List watchers. SDK alternative: client.watchers.list.', diff --git a/packages/server/src/tools/admin/manage_goals.ts b/packages/server/src/tools/admin/manage_goals.ts new file mode 100644 index 000000000..90feb9afe --- /dev/null +++ b/packages/server/src/tools/admin/manage_goals.ts @@ -0,0 +1,455 @@ +/** + * Tool: manage_goals + * + * Manage goal primitives — top-level handles that group watchers under a + * single user-facing intent (e.g. "Keep my CRM clean"). Goals own zero or + * more watchers via `watchers.goal_id` (ON DELETE SET NULL). The canvas + * surface (#801) consumes this list; goal-template loading from disk is a + * separate (owletto-side) concern. + * + * Actions: + * - create: Create a goal (slug, name, optional description / template_key / metadata). + * - update: Modify name / description / status / template_key / metadata. + * - get: Fetch a single goal by id or slug. + * - list: List goals in the org with optional status filter. + * - archive: Soft-disable a goal (sets status='archived'); watchers keep + * running, the goal just leaves the active canvas. + * - delete: Remove a goal row. Linked watchers survive via ON DELETE SET NULL. + */ + +import { type Static, Type } from '@sinclair/typebox'; +import { getDb } from '../../db/client'; +import { recordLifecycleEvent } from '../../utils/insert-event'; +import { requireOrgReadAccess, requireOrgWriteAccess } from '../../utils/organization-access'; +import type { ToolContext } from '../registry'; +import { routeAction } from './action-router'; + +// ============================================ +// Types +// ============================================ + +const GOAL_STATUSES = ['active', 'paused', 'archived'] as const; +export type GoalStatus = (typeof GOAL_STATUSES)[number]; + +export interface Goal { + id: number; + organization_id: string; + slug: string; + name: string; + description: string | null; + status: GoalStatus; + template_key: string | null; + metadata: Record; + created_at: string; + updated_at: string; + /** Count of watchers currently linked via watchers.goal_id. Populated by list/get. */ + watcher_count?: number; +} + +function mapGoalRow(row: Record): Goal { + const metadata = row.metadata; + return { + id: Number(row.id), + organization_id: String(row.organization_id), + slug: String(row.slug), + name: String(row.name), + description: row.description == null ? null : String(row.description), + status: String(row.status) as GoalStatus, + template_key: row.template_key == null ? null : String(row.template_key), + metadata: + metadata && typeof metadata === 'object' && !Array.isArray(metadata) + ? (metadata as Record) + : {}, + created_at: String(row.created_at), + updated_at: String(row.updated_at), + watcher_count: row.watcher_count == null ? undefined : Number(row.watcher_count), + }; +} + +// ============================================ +// Schema +// ============================================ + +const SLUG_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/; + +function assertValidSlug(slug: string): void { + if (!SLUG_PATTERN.test(slug)) { + throw new Error( + `Invalid goal slug '${slug}'. Use 1-64 lowercase alphanumerics, '-' or '_', starting with a letter or digit.` + ); + } +} + +function assertValidStatus(status: string): asserts status is GoalStatus { + if (!(GOAL_STATUSES as readonly string[]).includes(status)) { + throw new Error( + `Invalid goal status '${status}'. Expected one of: ${GOAL_STATUSES.join(', ')}` + ); + } +} + +const CreateGoalAction = Type.Object({ + action: Type.Literal('create'), + slug: Type.String({ + description: 'Stable slug, unique per organization (1-64 chars, [a-z0-9_-]).', + }), + name: Type.String({ description: 'Human-readable goal name.' }), + description: Type.Optional(Type.String()), + status: Type.Optional( + Type.Union([Type.Literal('active'), Type.Literal('paused'), Type.Literal('archived')], { + description: "Initial status. Defaults to 'active'.", + }) + ), + template_key: Type.Optional( + Type.String({ + description: 'Free-form pointer to the template that seeded this goal (out-of-scope loader).', + }) + ), + metadata: Type.Optional( + Type.Record(Type.String(), Type.Unknown(), { + description: 'Forward-compat blob (icon, color, owner, …).', + }) + ), +}); + +const UpdateGoalAction = Type.Object({ + action: Type.Literal('update'), + goal_id: Type.Optional(Type.Number({ description: 'Goal id (or use `slug`).' })), + slug: Type.Optional(Type.String({ description: 'Goal slug (or use `goal_id`).' })), + name: Type.Optional(Type.String()), + description: Type.Optional(Type.Union([Type.String(), Type.Null()])), + status: Type.Optional( + Type.Union([Type.Literal('active'), Type.Literal('paused'), Type.Literal('archived')]) + ), + template_key: Type.Optional(Type.Union([Type.String(), Type.Null()])), + metadata: Type.Optional(Type.Record(Type.String(), Type.Unknown())), + /** + * When true and `metadata` is provided, replace the stored metadata with + * exactly that object (declarative apply); when false/omitted, merge into + * the existing metadata (default). + */ + replace_metadata: Type.Optional(Type.Boolean()), +}); + +const GetGoalAction = Type.Object({ + action: Type.Literal('get'), + goal_id: Type.Optional(Type.Number()), + slug: Type.Optional(Type.String()), +}); + +const ListGoalsAction = Type.Object({ + action: Type.Literal('list'), + status: Type.Optional( + Type.Union([Type.Literal('active'), Type.Literal('paused'), Type.Literal('archived')]) + ), + limit: Type.Optional(Type.Number({ description: 'Max rows to return (default 100, max 500).' })), + offset: Type.Optional(Type.Number()), +}); + +const ArchiveGoalAction = Type.Object({ + action: Type.Literal('archive'), + goal_id: Type.Optional(Type.Number()), + slug: Type.Optional(Type.String()), +}); + +const DeleteGoalAction = Type.Object({ + action: Type.Literal('delete'), + goal_id: Type.Optional(Type.Number()), + slug: Type.Optional(Type.String()), +}); + +export const ManageGoalsSchema = Type.Union([ + CreateGoalAction, + UpdateGoalAction, + GetGoalAction, + ListGoalsAction, + ArchiveGoalAction, + DeleteGoalAction, +]); + +export type ManageGoalsArgs = Static; + +// ============================================ +// Result Types +// ============================================ + +type ManageGoalsResult = + | { action: 'create'; goal: Goal } + | { action: 'update'; goal: Goal } + | { action: 'get'; goal: Goal } + | { action: 'list'; goals: Goal[]; total: number; limit: number; offset: number } + | { action: 'archive'; goal: Goal } + | { action: 'delete'; deleted: true; goal_id: number; slug: string }; + +// ============================================ +// Main Function +// ============================================ + +export async function manageGoals( + args: ManageGoalsArgs, + _env: unknown, + ctx: ToolContext +): Promise { + return routeAction('manage_goals', args.action, ctx, { + create: () => handleCreate(args as Extract, ctx), + update: () => handleUpdate(args as Extract, ctx), + get: () => handleGet(args as Extract, ctx), + list: () => handleList(args as Extract, ctx), + archive: () => handleArchive(args as Extract, ctx), + delete: () => handleDelete(args as Extract, ctx), + }); +} + +// ============================================ +// Helpers +// ============================================ + +/** + * Resolve a goal by id or slug, scoped to the caller's organization. Throws + * if neither is provided or the row doesn't exist. + */ +async function resolveGoal( + goalId: number | undefined, + slug: string | undefined, + ctx: ToolContext +): Promise { + if (goalId === undefined && (slug === undefined || slug === null)) { + throw new Error('Either goal_id or slug is required'); + } + if (!ctx.organizationId) { + throw new Error('Organization context is required'); + } + + const sql = getDb(); + const rows = goalId !== undefined + ? await sql` + SELECT g.*, (SELECT COUNT(*)::int FROM watchers w WHERE w.goal_id = g.id) AS watcher_count + FROM goals g + WHERE g.id = ${goalId} AND g.organization_id = ${ctx.organizationId} + LIMIT 1 + ` + : await sql` + SELECT g.*, (SELECT COUNT(*)::int FROM watchers w WHERE w.goal_id = g.id) AS watcher_count + FROM goals g + WHERE g.slug = ${slug as string} AND g.organization_id = ${ctx.organizationId} + LIMIT 1 + `; + + if (rows.length === 0) { + throw new Error(`Goal ${goalId ?? `'${slug}'`} not found`); + } + return mapGoalRow(rows[0] as Record); +} + +// ============================================ +// Action Handlers +// ============================================ + +async function handleCreate( + args: Extract, + ctx: ToolContext +): Promise { + const sql = getDb(); + await requireOrgWriteAccess(sql, ctx); + if (!ctx.organizationId) { + throw new Error('Organization context is required'); + } + + assertValidSlug(args.slug); + const status: GoalStatus = args.status ?? 'active'; + // status is already constrained by the Type.Union, but assert for the + // CHECK constraint mirror — keeps the error path predictable for handler- + // direct callers that skip the typebox layer. + assertValidStatus(status); + + const metadata = args.metadata ?? {}; + + const inserted = await sql` + INSERT INTO goals (organization_id, slug, name, description, status, template_key, metadata) + VALUES ( + ${ctx.organizationId}, + ${args.slug}, + ${args.name}, + ${args.description ?? null}, + ${status}, + ${args.template_key ?? null}, + ${sql.json(metadata)} + ) + RETURNING * + `; + + const goal = mapGoalRow(inserted[0] as Record); + goal.watcher_count = 0; + + recordLifecycleEvent({ + organizationId: ctx.organizationId, + entityType: 'goal', + op: 'created', + entityId: goal.id, + summary: `Goal '${goal.name}' created`, + createdBy: ctx.userId, + }); + + return { action: 'create', goal }; +} + +async function handleUpdate( + args: Extract, + ctx: ToolContext +): Promise { + const sql = getDb(); + await requireOrgWriteAccess(sql, ctx); + const existing = await resolveGoal(args.goal_id, args.slug, ctx); + + if (args.status !== undefined) { + assertValidStatus(args.status); + } + + const replaceMetadata = args.replace_metadata === true && args.metadata !== undefined; + const hasMetadata = args.metadata !== undefined; + + // description / template_key are tri-state (undefined = leave, null = clear). + const hasDescriptionArg = Object.hasOwn(args, 'description'); + const descriptionValue = hasDescriptionArg ? (args.description ?? null) : null; + const hasTemplateKeyArg = Object.hasOwn(args, 'template_key'); + const templateKeyValue = hasTemplateKeyArg ? (args.template_key ?? null) : null; + + const updated = await sql` + UPDATE goals + SET name = COALESCE(${args.name ?? null}::text, name), + description = CASE WHEN ${hasDescriptionArg} THEN ${descriptionValue}::text ELSE description END, + status = COALESCE(${args.status ?? null}::text, status), + template_key = CASE WHEN ${hasTemplateKeyArg} THEN ${templateKeyValue}::text ELSE template_key END, + metadata = ${ + replaceMetadata + ? sql`${sql.json(args.metadata ?? {})}::jsonb` + : hasMetadata + ? sql`COALESCE(metadata, '{}'::jsonb) || ${sql.json(args.metadata ?? {})}::jsonb` + : sql`metadata` + }, + updated_at = now() + WHERE id = ${existing.id} AND organization_id = ${ctx.organizationId} + RETURNING *, (SELECT COUNT(*)::int FROM watchers w WHERE w.goal_id = goals.id) AS watcher_count + `; + + if (updated.length === 0) { + throw new Error(`Goal ${existing.id} not found`); + } + + return { action: 'update', goal: mapGoalRow(updated[0] as Record) }; +} + +async function handleGet( + args: Extract, + ctx: ToolContext +): Promise { + const sql = getDb(); + await requireOrgReadAccess(sql, ctx); + const goal = await resolveGoal(args.goal_id, args.slug, ctx); + return { action: 'get', goal }; +} + +async function handleList( + args: Extract, + ctx: ToolContext +): Promise { + const sql = getDb(); + await requireOrgReadAccess(sql, ctx); + if (!ctx.organizationId) { + throw new Error('Organization context is required'); + } + if (args.status !== undefined) { + assertValidStatus(args.status); + } + + const limit = Math.min(args.limit ?? 100, 500); + const offset = args.offset ?? 0; + + const rows = args.status + ? await sql` + SELECT g.*, (SELECT COUNT(*)::int FROM watchers w WHERE w.goal_id = g.id) AS watcher_count + FROM goals g + WHERE g.organization_id = ${ctx.organizationId} AND g.status = ${args.status} + ORDER BY g.created_at DESC + LIMIT ${limit} OFFSET ${offset} + ` + : await sql` + SELECT g.*, (SELECT COUNT(*)::int FROM watchers w WHERE w.goal_id = g.id) AS watcher_count + FROM goals g + WHERE g.organization_id = ${ctx.organizationId} + ORDER BY g.created_at DESC + LIMIT ${limit} OFFSET ${offset} + `; + + const goals = (rows as Record[]).map(mapGoalRow); + return { action: 'list', goals, total: goals.length, limit, offset }; +} + +async function handleArchive( + args: Extract, + ctx: ToolContext +): Promise { + const sql = getDb(); + await requireOrgWriteAccess(sql, ctx); + const existing = await resolveGoal(args.goal_id, args.slug, ctx); + + const updated = await sql` + UPDATE goals + SET status = 'archived', updated_at = now() + WHERE id = ${existing.id} AND organization_id = ${ctx.organizationId} + RETURNING *, (SELECT COUNT(*)::int FROM watchers w WHERE w.goal_id = goals.id) AS watcher_count + `; + + if (updated.length === 0) { + throw new Error(`Goal ${existing.id} not found`); + } + + const goal = mapGoalRow(updated[0] as Record); + + recordLifecycleEvent({ + organizationId: ctx.organizationId!, + entityType: 'goal', + op: 'updated', + entityId: goal.id, + summary: `Goal '${goal.name}' archived`, + extra: { status: 'archived' }, + createdBy: ctx.userId, + }); + + return { action: 'archive', goal }; +} + +async function handleDelete( + args: Extract, + ctx: ToolContext +): Promise { + const sql = getDb(); + await requireOrgWriteAccess(sql, ctx); + const existing = await resolveGoal(args.goal_id, args.slug, ctx); + + const deleted = await sql` + DELETE FROM goals + WHERE id = ${existing.id} AND organization_id = ${ctx.organizationId} + RETURNING id, slug, name + `; + + if (deleted.length === 0) { + throw new Error(`Goal ${existing.id} not found`); + } + + recordLifecycleEvent({ + organizationId: ctx.organizationId!, + entityType: 'goal', + op: 'deleted', + entityId: existing.id, + summary: `Goal '${existing.name}' deleted`, + createdBy: ctx.userId, + }); + + return { + action: 'delete', + deleted: true, + goal_id: existing.id, + slug: existing.slug, + }; +} diff --git a/packages/server/src/tools/admin/manage_watchers.ts b/packages/server/src/tools/admin/manage_watchers.ts index f105561b9..f893ee5ee 100644 --- a/packages/server/src/tools/admin/manage_watchers.ts +++ b/packages/server/src/tools/admin/manage_watchers.ts @@ -357,6 +357,12 @@ export const ManageWatchersSchema = Type.Object({ description: '[create/update] Agent ID that owns/executes this watcher.', }) ), + goal_id: Type.Optional( + Type.Union([Type.Number(), Type.Null()], { + description: + '[create/update] Optional goal_id linking this watcher to a goal (manage_goals). Pass null to unlink.', + }) + ), scheduler_client_id: Type.Optional( Type.String({ description: @@ -1047,11 +1053,31 @@ async function handleCreate( const nextRunAtVal = args.schedule ? nextRunAt(args.schedule) : null; + // Validate goal_id (if provided) belongs to the same org. The FK alone + // protects referential integrity but doesn't enforce org-scoping — + // without this check, a caller could attach a watcher to another org's + // goal id. + const goalIdValue = + args.goal_id === null || args.goal_id === undefined ? null : Number(args.goal_id); + if (goalIdValue !== null) { + const goalRows = await tx` + SELECT id FROM goals + WHERE id = ${goalIdValue} AND organization_id = ${organizationId} + LIMIT 1 + `; + if (goalRows.length === 0) { + throw new ToolUserError( + `goal_id ${goalIdValue} not found in this organization`, + 404 + ); + } + } + // 1. Create watcher row await tx` INSERT INTO watchers ( id, name, slug, organization_id, entity_ids, - schedule, next_run_at, agent_id, scheduler_client_id, model_config, sources, version, + schedule, next_run_at, agent_id, goal_id, scheduler_client_id, model_config, sources, version, current_version_id, tags, status, created_by, created_at, updated_at, watcher_group_id, device_worker_id, agent_kind, @@ -1060,7 +1086,7 @@ async function handleCreate( ${watcherId}, ${args.name ?? args.slug}, ${args.slug}, ${organizationId}, ${`{${entityIdsArray.join(',')}}`}::bigint[], ${args.schedule ?? null}, ${nextRunAtVal}, - ${args.agent_id ?? null}, ${args.scheduler_client_id ?? null}, + ${args.agent_id ?? null}, ${goalIdValue}, ${args.scheduler_client_id ?? null}, ${sql.json(args.model_config || {})}, ${sql.json(sources)}, 1, NULL, ${toTextArrayParam(args.tags || [])}::text[], 'active', ${createdBy}, NOW(), NOW(), @@ -1303,10 +1329,37 @@ async function handleUpdate( } } + const hasGoalIdArg = Object.hasOwn(args, 'goal_id'); + const goalIdValue = + hasGoalIdArg && args.goal_id !== null && args.goal_id !== undefined + ? Number(args.goal_id) + : null; + if (hasGoalIdArg && goalIdValue !== null) { + // Org-scope check on the target goal — see handleCreate's matching + // validation for the FK-vs-cross-org rationale. + const orgRows = await sql` + SELECT organization_id FROM watchers WHERE id = ${args.watcher_id} LIMIT 1 + `; + const ownerOrg = (orgRows[0] as { organization_id: string | null } | undefined) + ?.organization_id; + const goalRows = await sql` + SELECT id FROM goals + WHERE id = ${goalIdValue} AND organization_id = ${ownerOrg ?? ''} + LIMIT 1 + `; + if (goalRows.length === 0) { + throw new ToolUserError( + `goal_id ${goalIdValue} not found in this organization`, + 404 + ); + } + } + const updatedFields: string[] = []; if (args.model_config !== undefined) updatedFields.push('model_config'); if (args.schedule !== undefined) updatedFields.push('schedule'); if (args.agent_id !== undefined) updatedFields.push('agent_id'); + if (hasGoalIdArg) updatedFields.push('goal_id'); if (args.scheduler_client_id !== undefined) updatedFields.push('scheduler_client_id'); if (args.tags !== undefined) updatedFields.push('tags'); if (args.device_worker_id !== undefined) updatedFields.push('device_worker_id'); @@ -1333,6 +1386,7 @@ async function handleUpdate( schedule = CASE WHEN ${args.schedule !== undefined} THEN ${scheduleValue} ELSE schedule END, next_run_at = CASE WHEN ${args.schedule !== undefined} THEN ${nextRunAtVal}::timestamptz ELSE next_run_at END, agent_id = CASE WHEN ${args.agent_id !== undefined} THEN ${args.agent_id ?? null} ELSE agent_id END, + goal_id = CASE WHEN ${hasGoalIdArg} THEN ${goalIdValue}::bigint ELSE goal_id END, scheduler_client_id = CASE WHEN ${args.scheduler_client_id !== undefined} THEN ${args.scheduler_client_id ?? null} ELSE scheduler_client_id END, tags = CASE WHEN ${args.tags !== undefined} THEN ${toTextArrayParam(args.tags || [])}::text[] ELSE tags END, device_worker_id = CASE WHEN ${args.device_worker_id !== undefined} THEN ${args.device_worker_id ?? null}::uuid ELSE device_worker_id END, diff --git a/packages/server/src/tools/get_watchers.ts b/packages/server/src/tools/get_watchers.ts index fb1840c8a..88734eba1 100644 --- a/packages/server/src/tools/get_watchers.ts +++ b/packages/server/src/tools/get_watchers.ts @@ -202,6 +202,7 @@ interface WatcherQueryRow { schedule: string | null; next_run_at: string | null; agent_id: string | null; + goal_id: number | null; scheduler_client_id: string | null; version: number; current_version_id: number | null; @@ -547,6 +548,7 @@ export async function getWatcher( i.schedule, i.next_run_at, i.agent_id, + i.goal_id, i.scheduler_client_id, i.version, i.current_version_id, @@ -808,6 +810,7 @@ export async function getWatcher( schedule: watcherRow.schedule, next_run_at: watcherRow.next_run_at, agent_id: watcherRow.agent_id, + goal_id: watcherRow.goal_id ?? null, scheduler_client_id: watcherRow.scheduler_client_id, version: pinnedVersion, sources: watcherSources, diff --git a/packages/server/src/types/watchers.ts b/packages/server/src/types/watchers.ts index 319fb52a7..6f173adc2 100644 --- a/packages/server/src/types/watchers.ts +++ b/packages/server/src/types/watchers.ts @@ -128,6 +128,11 @@ export interface WatcherMetadata { schedule?: string | null; next_run_at?: string | null; agent_id?: string | null; + /** + * Optional FK into `goals.id`. NULL/undefined means the watcher is + * "ungrouped" — historical behavior. See manage_goals + #800. + */ + goal_id?: number | null; scheduler_client_id?: string | null; version: number; sources: WatcherSource[];