diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 4ef7d3e1702..48f91825806 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -170,6 +170,7 @@ Invariant: every business record belongs to exactly one company. - `permissions` jsonb not null default `{}` - `last_heartbeat_at` timestamptz null - `metadata` jsonb null +- `created_by_user_id` text null; the human user who created or requested the agent. Null for agent-requested and system-created agents. The agent overview shows it only when the company has more than one human member; the local Board principal is never counted or shown. Invariants: diff --git a/packages/db/src/migrations/0343_new_mentor.sql b/packages/db/src/migrations/0343_new_mentor.sql new file mode 100644 index 00000000000..a5cd68472cb --- /dev/null +++ b/packages/db/src/migrations/0343_new_mentor.sql @@ -0,0 +1,14 @@ +ALTER TABLE "agents" ADD COLUMN IF NOT EXISTS "created_by_user_id" text; +--> statement-breakpoint +UPDATE "agents" SET "created_by_user_id" = ( + SELECT "activity_log"."actor_id" + FROM "activity_log" + WHERE "activity_log"."entity_type" = 'agent' + AND "activity_log"."entity_id" = "agents"."id"::text + AND "activity_log"."company_id" = "agents"."company_id" + AND "activity_log"."action" IN ('agent.created', 'agent.hire_created') + AND "activity_log"."actor_type" = 'user' + ORDER BY "activity_log"."created_at" ASC + LIMIT 1 +) +WHERE "created_by_user_id" IS NULL; diff --git a/packages/db/src/migrations/meta/0338_snapshot.json b/packages/db/src/migrations/meta/0343_snapshot.json similarity index 99% rename from packages/db/src/migrations/meta/0338_snapshot.json rename to packages/db/src/migrations/meta/0343_snapshot.json index cc521642c51..e2285ced73e 100644 --- a/packages/db/src/migrations/meta/0338_snapshot.json +++ b/packages/db/src/migrations/meta/0343_snapshot.json @@ -1,6 +1,6 @@ { - "id": "108e07e7-19ab-4f16-8400-fd733f77afdc", - "prevId": "65f48b10-7f94-449e-b2ce-a68f220910f3", + "id": "b187fa5b-b867-48a2-89b0-e16f71123411", + "prevId": "e9a4bbc2-b22c-48a1-a74c-f108cd7b20ec", "version": "7", "dialect": "postgresql", "tables": { @@ -4098,6 +4098,12 @@ "primaryKey": false, "notNull": false }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, "created_at": { "name": "created_at", "type": "timestamp with time zone", @@ -16324,6 +16330,18 @@ "primaryKey": false, "notNull": false }, + "owner_agent_id": { + "name": "owner_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owner_plugin_id": { + "name": "owner_plugin_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, "user_secret_definition_id": { "name": "user_secret_definition_id", "type": "uuid", @@ -16552,6 +16570,40 @@ "method": "btree", "with": {} }, + "company_secrets_instance_owner_key_uq": { + "name": "company_secrets_instance_owner_key_uq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner_plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"company_secrets\".\"scope\" = 'instance' and \"company_secrets\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, "company_secrets_provider_config_idx": { "name": "company_secrets_provider_config_idx", "columns": [ @@ -16654,6 +16706,19 @@ "onDelete": "no action", "onUpdate": "no action" }, + "company_secrets_owner_plugin_id_plugins_id_fk": { + "name": "company_secrets_owner_plugin_id_plugins_id_fk", + "tableFrom": "company_secrets", + "tableTo": "plugins", + "columnsFrom": [ + "owner_plugin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, "company_secrets_user_secret_definition_id_user_secret_definitions_id_fk": { "name": "company_secrets_user_secret_definition_id_user_secret_definitions_id_fk", "tableFrom": "company_secrets", @@ -16692,6 +16757,21 @@ ], "onDelete": "set null", "onUpdate": "no action" + }, + "company_secrets_company_id_owner_agent_id_agents_company_id_id_fk": { + "name": "company_secrets_company_id_owner_agent_id_agents_company_id_id_fk", + "tableFrom": "company_secrets", + "tableTo": "agents", + "columnsFrom": [ + "company_id", + "owner_agent_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, @@ -16700,7 +16780,7 @@ "checkConstraints": { "company_secrets_scope_shape_check": { "name": "company_secrets_scope_shape_check", - "value": "(\n \"company_secrets\".\"scope\" = 'company'\n and \"company_secrets\".\"owner_user_id\" is null\n and \"company_secrets\".\"user_secret_definition_id\" is null\n ) or (\n \"company_secrets\".\"scope\" = 'user'\n and \"company_secrets\".\"owner_user_id\" is not null\n and \"company_secrets\".\"user_secret_definition_id\" is not null\n )" + "value": "(\n \"company_secrets\".\"scope\" = 'company'\n and \"company_secrets\".\"owner_user_id\" is null\n and \"company_secrets\".\"user_secret_definition_id\" is null\n and \"company_secrets\".\"owner_agent_id\" is null and \"company_secrets\".\"owner_plugin_id\" is null\n ) or (\n \"company_secrets\".\"scope\" = 'user'\n and \"company_secrets\".\"owner_user_id\" is not null\n and \"company_secrets\".\"user_secret_definition_id\" is not null\n and \"company_secrets\".\"owner_agent_id\" is null and \"company_secrets\".\"owner_plugin_id\" is null\n ) or (\n \"company_secrets\".\"scope\" = 'instance'\n and \"company_secrets\".\"owner_user_id\" is null and \"company_secrets\".\"user_secret_definition_id\" is null\n and \"company_secrets\".\"owner_agent_id\" is not null and \"company_secrets\".\"owner_plugin_id\" is not null\n )" } }, "isRLSEnabled": false @@ -16778,6 +16858,53 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.company_skill_import_budgets": { + "name": "company_skill_import_budgets", + "schema": "", + "columns": { + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "available_bytes": { + "name": "available_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 536870912 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "company_skill_import_budgets_company_id_companies_id_fk": { + "name": "company_skill_import_budgets_company_id_companies_id_fk", + "tableFrom": "company_skill_import_budgets", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.company_skill_source_entries": { "name": "company_skill_source_entries", "schema": "", @@ -17017,6 +17144,72 @@ "notNull": true, "default": true }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "automatic_updates": { + "name": "automatic_updates", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "import_new_skills": { + "name": "import_new_skills", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "access_scope": { + "name": "access_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'company'" + }, + "refresh_user_id": { + "name": "refresh_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_agent_id": { + "name": "refresh_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "refresh_local_trusted": { + "name": "refresh_local_trusted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "refresh_grant_id": { + "name": "refresh_grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "failure_count": { + "name": "failure_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, "revision": { "name": "revision", "type": "integer", @@ -17042,6 +17235,26 @@ "primaryKey": false, "notNull": false }, + "last_scan_tree_sha": { + "name": "last_scan_tree_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "automatic_budget_bytes": { + "name": "automatic_budget_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 536870912 + }, + "automatic_budget_at": { + "name": "automatic_budget_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, "last_error": { "name": "last_error", "type": "text", @@ -17092,6 +17305,7 @@ } ], "isUnique": true, + "where": "\"company_skill_sources\".\"removed_at\" is null", "concurrently": false, "method": "btree", "with": {} @@ -17119,6 +17333,7 @@ } ], "isUnique": true, + "where": "\"company_skill_sources\".\"removed_at\" is null", "concurrently": false, "method": "btree", "with": {} @@ -17137,13 +17352,275 @@ ], "onDelete": "cascade", "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_skill_sources": { + "name": "project_skill_sources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" }, - "company_skill_sources_connection_id_tool_connections_id_fk": { - "name": "company_skill_sources_connection_id_tool_connections_id_fk", - "tableFrom": "company_skill_sources", - "tableTo": "tool_connections", + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repository_url": { + "name": "repository_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tracking_ref": { + "name": "tracking_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "refresh_user_id": { + "name": "refresh_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_agent_id": { + "name": "refresh_agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "refresh_local_trusted": { + "name": "refresh_local_trusted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "refresh_grant_id": { + "name": "refresh_grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "failure_count": { + "name": "failure_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_token": { + "name": "lease_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_skill_sources_workspace_repo_ref_idx": { + "name": "project_skill_sources_workspace_repo_ref_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repository_url", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tracking_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_skill_sources_source_project_idx": { + "name": "project_skill_sources_source_project_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_skill_sources_sync_idx": { + "name": "project_skill_sources_sync_idx", + "columns": [ + { + "expression": "active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_skill_sources_company_id_companies_id_fk": { + "name": "project_skill_sources_company_id_companies_id_fk", + "tableFrom": "project_skill_sources", + "tableTo": "companies", "columnsFrom": [ - "connection_id" + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_skill_sources_project_id_projects_id_fk": { + "name": "project_skill_sources_project_id_projects_id_fk", + "tableFrom": "project_skill_sources", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_skill_sources_workspace_id_project_workspaces_id_fk": { + "name": "project_skill_sources_workspace_id_project_workspaces_id_fk", + "tableFrom": "project_skill_sources", + "tableTo": "project_workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "project_skill_sources_source_id_company_skill_sources_id_fk": { + "name": "project_skill_sources_source_id_company_skill_sources_id_fk", + "tableFrom": "project_skill_sources", + "tableTo": "company_skill_sources", + "columnsFrom": [ + "source_id" ], "columnsTo": [ "id" @@ -18603,6 +19080,12 @@ "notNull": true, "default": "'company'" }, + "access_source_id": { + "name": "access_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, "public_share_token": { "name": "public_share_token", "type": "text", @@ -46126,7 +46609,7 @@ }, "resource_lifecycle_events_action_check": { "name": "resource_lifecycle_events_action_check", - "value": "\"resource_lifecycle_events\".\"action\" = 'create' OR (\"resource_lifecycle_events\".\"resource_type\" = 'project' AND \"resource_lifecycle_events\".\"action\" IN ('update', 'archive')) OR (\"resource_lifecycle_events\".\"resource_type\" = 'agent' AND \"resource_lifecycle_events\".\"action\" IN ('pause', 'resume', 'terminate'))" + "value": "\"resource_lifecycle_events\".\"action\" = 'create' OR (\"resource_lifecycle_events\".\"resource_type\" = 'project' AND \"resource_lifecycle_events\".\"action\" IN ('update', 'archive', 'delete')) OR (\"resource_lifecycle_events\".\"resource_type\" = 'agent' AND \"resource_lifecycle_events\".\"action\" IN ('pause', 'resume', 'terminate'))" } }, "isRLSEnabled": false diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 2bd7d8fcc9b..b35f78624ec 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -2388,6 +2388,13 @@ "when": 1791921858146, "tag": "0342_dry_shen", "breakpoints": true + }, + { + "idx": 343, + "version": "7", + "when": 1791926375117, + "tag": "0343_new_mentor", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/agents.ts b/packages/db/src/schema/agents.ts index 04e781f568f..03152d6dffd 100644 --- a/packages/db/src/schema/agents.ts +++ b/packages/db/src/schema/agents.ts @@ -46,6 +46,7 @@ export const agents = pgTable( permissions: jsonb("permissions").$type>().notNull().default({}), lastHeartbeatAt: timestamp("last_heartbeat_at", { withTimezone: true }), metadata: jsonb("metadata").$type>(), + createdByUserId: text("created_by_user_id"), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, diff --git a/packages/shared/src/types/agent.ts b/packages/shared/src/types/agent.ts index 2e0f121664e..4cac11e5a02 100644 --- a/packages/shared/src/types/agent.ts +++ b/packages/shared/src/types/agent.ts @@ -109,6 +109,7 @@ export interface Agent { lastHeartbeatAt: Date | null; metadata: Record | null; orgChainHealth?: AgentOrgChainHealth; + createdByUserId?: string | null; createdAt: Date; updatedAt: Date; } diff --git a/server/src/__tests__/built-in-agents.test.ts b/server/src/__tests__/built-in-agents.test.ts index 920a1c9537c..3e0d4c9079e 100644 --- a/server/src/__tests__/built-in-agents.test.ts +++ b/server/src/__tests__/built-in-agents.test.ts @@ -295,6 +295,18 @@ describeEmbeddedPostgres("built-in agents", () => { expect(rows).toHaveLength(1); }); + it("records the human who enabled a built-in agent and leaves automatic creation unattributed", async () => { + const enabledCompanyId = await seedCompany({ requireApproval: false }); + const automaticCompanyId = await seedCompany({ requireApproval: false }); + const builtIns = builtInAgentService(db); + + const enabled = await builtIns.provision(enabledCompanyId, "briefs", {}, { requestedByUserId: "board-user" }); + const automatic = await builtIns.provision(automaticCompanyId, "briefs"); + + expect(enabled.state.agent?.createdByUserId).toBe("board-user"); + expect(automatic.state.agent?.createdByUserId).toBeNull(); + }); + it("routes policy-gated built-in provisioning through a pending hire approval", async () => { const companyId = await seedCompany(); const builtIns = builtInAgentService(db); @@ -336,7 +348,7 @@ describeEmbeddedPostgres("built-in agents", () => { const rowsBeforeApproval = await db.select().from(agents).where(eq(agents.companyId, companyId)); expect(rowsBeforeApproval).toHaveLength(1); - expect(rowsBeforeApproval[0]).toMatchObject({ status: "pending_approval" }); + expect(rowsBeforeApproval[0]).toMatchObject({ status: "pending_approval", createdByUserId: "board-user" }); await expect(builtIns.requireBuiltInAgent(companyId, "briefs")).rejects.toMatchObject({ status: 412, diff --git a/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts b/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts index b40d4897a76..fc84aa79212 100644 --- a/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts +++ b/server/src/modules/agent-lifecycle/__tests__/lifecycle.test.ts @@ -62,6 +62,36 @@ const support = await getEmbeddedPostgresTestSupport(); } }); + it("records the human creator from options and ignores a creator in the insert data", async () => { + const lifecycle = createAgentLifecycle(db); + const created = await lifecycle.requestHire(companyId, { name: "Created by member", createdByUserId: "spoofed-user" }, { createdByUserId: "member-1" }); + expect(created.createdByUserId).toBe("member-1"); + expect((await current(created.id)).createdByUserId).toBe("member-1"); + const system = await lifecycle.requestHire(companyId, { name: "System hire", createdByUserId: "spoofed-user" }); + expect((await current(system.id)).createdByUserId).toBeNull(); + }); + + it("backfills the creator from the earliest user creation activity in the same company", async () => { + const [byUser, byAgent, foreign] = await db.insert(agents).values(["By user", "By agent", "Foreign log"].map(name => ({ companyId, name }))).returning(); + const otherCompanyId = randomUUID(); + await db.insert(companies).values({ id: otherCompanyId, name: "Other", issuePrefix: `O${otherCompanyId.slice(0, 7)}` }); + await db.insert(activityLog).values([ + { companyId, actorType: "user", actorId: "first-user", action: "agent.hire_created", entityType: "agent", entityId: byUser.id, createdAt: new Date("2026-01-01T00:00:00Z") }, + { companyId, actorType: "user", actorId: "later-user", action: "agent.created", entityType: "agent", entityId: byUser.id, createdAt: new Date("2026-01-02T00:00:00Z") }, + { companyId, actorType: "agent", actorId: randomUUID(), action: "agent.hire_created", entityType: "agent", entityId: byAgent.id }, + { companyId: otherCompanyId, actorType: "user", actorId: "foreign-user", action: "agent.created", entityType: "agent", entityId: foreign.id }, + ]); + const migration = readFileSync(new URL("../../../../../packages/db/src/migrations/0343_new_mentor.sql", import.meta.url), "utf8"); + for (let pass = 0; pass < 2; pass++) { + for (const statement of migration.split("--> statement-breakpoint")) { + await db.execute(sql.raw(statement)); + } + } + expect((await current(byUser.id)).createdByUserId).toBe("first-user"); + expect((await current(byAgent.id)).createdByUserId).toBeNull(); + expect((await current(foreign.id)).createdByUserId).toBeNull(); + }); + it("commits the record before preparation and verifies before admission", async () => { const observed: string[] = []; const work = worker(async agent => { diff --git a/server/src/modules/agent-lifecycle/adapters/records.ts b/server/src/modules/agent-lifecycle/adapters/records.ts index bf2ec0cd47f..131f8befc06 100644 --- a/server/src/modules/agent-lifecycle/adapters/records.ts +++ b/server/src/modules/agent-lifecycle/adapters/records.ts @@ -19,6 +19,7 @@ export function agentRecords(db: Db, effects: LifecycleEffects) { .insert(agents) .values({ ...prepared, + createdByUserId: options?.createdByUserId ?? null, lifecycleState: data.status === "pending_approval" ? "pending_approval" : data.status === "terminated" ? "terminated" : data.status === "paused" ? "paused" : "preparing", lifecycleVersion: 1, lifecycleHolds: data.status === "paused" ? [data.pauseReason ?? "manual"] : [], diff --git a/server/src/services/built-in-agents.ts b/server/src/services/built-in-agents.ts index 989f7b93b3d..af69debdc5c 100644 --- a/server/src/services/built-in-agents.ts +++ b/server/src/services/built-in-agents.ts @@ -1726,7 +1726,7 @@ export function builtInAgentService(db: Db) { companyId: string, key: string, input: BuiltInAgentProvisionInput = {}, - options: { isRaceRetry?: boolean } = {}, + options: { isRaceRetry?: boolean; createdByUserId?: string | null } = {}, ) { const definition = requireBuiltInAgentDefinition(key); await ensureCompany(companyId); @@ -1800,13 +1800,13 @@ export function builtInAgentService(db: Db) { permissions: definition.defaultPermissions ?? {}, spentMonthlyCents: 0, lastHeartbeatAt: null, - }, { allowBuiltInAgentMetadata: true }) as Agent; + }, { allowBuiltInAgentMetadata: true, createdByUserId: options.createdByUserId ?? null }) as Agent; } catch (error) { // Lost the provisioning race: a concurrent writer inserted the row first // and the partial unique index rejected ours. Re-run once; the winning // row now exists, so we take the update path instead of inserting again. if (!options.isRaceRetry && isBuiltInAgentMarkerConflict(error)) { - return ensure(companyId, key, input, { isRaceRetry: true }); + return ensure(companyId, key, input, { isRaceRetry: true, createdByUserId: options.createdByUserId }); } throw error; } @@ -1839,7 +1839,7 @@ export function builtInAgentService(db: Db) { const definition = requireBuiltInAgentDefinition(key); const company = await ensureCompany(companyId); if (!company.requireBoardApprovalForNewAgents) { - return { state: await ensure(companyId, key, input), approval: null }; + return { state: await ensure(companyId, key, input, { createdByUserId: actor.requestedByUserId ?? null }), approval: null }; } await assertKnownBuiltInAgentModel(definition, input); @@ -1901,7 +1901,7 @@ export function builtInAgentService(db: Db) { permissions: definition.defaultPermissions ?? {}, spentMonthlyCents: 0, lastHeartbeatAt: null, - }, { allowBuiltInAgentMetadata: true }) as Agent; + }, { allowBuiltInAgentMetadata: true, createdByUserId: actor.requestedByUserId ?? null }) as Agent; } catch (error) { // Lost the provisioning race: a concurrent writer inserted the row (and // its own hire approval) first, and the partial unique index rejected diff --git a/ui/src/lib/agent-creator.test.ts b/ui/src/lib/agent-creator.test.ts new file mode 100644 index 00000000000..f582ba9c1d3 --- /dev/null +++ b/ui/src/lib/agent-creator.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import type { CompanyUserDirectoryEntry } from "../api/access"; +import { humanAgentCreatorUserId, resolveAgentCreatorLabel } from "./agent-creator"; + +function member(principalId: string, name: string | null, email: string | null = null): CompanyUserDirectoryEntry { + return { principalId, status: "active", user: { id: principalId, name, email, image: null } }; +} + +describe("agent creator label", () => { + const alice = member("user-alice", "Alice"); + const bob = member("user-bob", null, "bob@example.com"); + const board = member("local-board", "Board"); + + it("names the creator once more than one human shares the company", () => { + expect(resolveAgentCreatorLabel("user-alice", [alice, bob])).toBe("Alice"); + expect(resolveAgentCreatorLabel("user-bob", [alice, bob])).toBe("bob@example.com"); + }); + + it("stays hidden for a single human, even alongside the local Board principal", () => { + expect(resolveAgentCreatorLabel("user-alice", [alice])).toBeNull(); + expect(resolveAgentCreatorLabel("user-alice", [alice, board])).toBeNull(); + }); + + it("never shows the local Board principal or a missing creator", () => { + expect(humanAgentCreatorUserId("local-board")).toBeNull(); + expect(resolveAgentCreatorLabel("local-board", [alice, bob, board])).toBeNull(); + expect(resolveAgentCreatorLabel(null, [alice, bob])).toBeNull(); + expect(resolveAgentCreatorLabel("user-alice", undefined)).toBeNull(); + }); + + it("labels a creator who is no longer an active member", () => { + expect(resolveAgentCreatorLabel("user-carol", [alice, bob])).toBe("Former member"); + }); +}); diff --git a/ui/src/lib/agent-creator.ts b/ui/src/lib/agent-creator.ts new file mode 100644 index 00000000000..8ab24b6b692 --- /dev/null +++ b/ui/src/lib/agent-creator.ts @@ -0,0 +1,20 @@ +import type { CompanyUserDirectoryEntry } from "../api/access"; + +const LOCAL_BOARD_USER_ID = "local-board"; + +export function humanAgentCreatorUserId(createdByUserId: string | null | undefined): string | null { + return createdByUserId && createdByUserId !== LOCAL_BOARD_USER_ID ? createdByUserId : null; +} + +export function resolveAgentCreatorLabel( + createdByUserId: string | null | undefined, + users: CompanyUserDirectoryEntry[] | undefined, +): string | null { + const creatorUserId = humanAgentCreatorUserId(createdByUserId); + if (!creatorUserId || !users) return null; + const humans = users.filter((entry) => entry.principalId !== LOCAL_BOARD_USER_ID); + if (humans.length < 2) return null; + const creator = humans.find((entry) => entry.principalId === creatorUserId); + if (!creator) return "Former member"; + return creator.user?.name?.trim() || creator.user?.email || "Unknown user"; +} diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index cb7ea54649a..5eb6466feab 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -130,6 +130,7 @@ import { import { buildPermissionsForTrustPreset, getTrustPreset } from "../lib/trust-policy-ui"; import { redactHomePathUserSegments, redactHomePathUserSegmentsInValue } from "@paperclipai/adapter-utils"; import { agentRouteRef } from "../lib/utils"; +import { humanAgentCreatorUserId, resolveAgentCreatorLabel } from "../lib/agent-creator"; import { isStarred, resourceMembershipState, @@ -1743,6 +1744,14 @@ export function AgentOverview({ queryKey: [...queryKeys.agents.identity(agent.id), lastRun?.id, lastRun?.status], queryFn: () => agentsApi.getIdentity(agent.id, agent.companyId), }); + const hasHumanCreator = Boolean(humanAgentCreatorUserId(agent.createdByUserId)); + const userDirectory = useQuery({ + queryKey: queryKeys.access.companyUserDirectory(agent.companyId), + queryFn: () => accessApi.listUserDirectory(agent.companyId), + enabled: hasHumanCreator, + retry: false, + }); + const creatorLabel = resolveAgentCreatorLabel(agent.createdByUserId, userDirectory.data?.users); return (
@@ -1764,6 +1773,11 @@ export function AgentOverview({ ) : Board} {directReportCount} + {hasHumanCreator && userDirectory.isError ? ( + Could not load creator + ) : creatorLabel ? ( + {creatorLabel} + ) : null} {identity.isPending ? Loading… : identity.isError ? Could not load public key diff --git a/ui/src/pages/AgentOverview.test.tsx b/ui/src/pages/AgentOverview.test.tsx index 7149a65788a..dfae853b48d 100644 --- a/ui/src/pages/AgentOverview.test.tsx +++ b/ui/src/pages/AgentOverview.test.tsx @@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { AgentDetail, AgentRuntimeState, HeartbeatRun, Issue } from "@paperclipai/shared"; import { describe, expect, it, vi } from "vitest"; import { AgentOverview } from "./AgentDetail"; +import { queryKeys } from "../lib/queryKeys"; vi.mock("@/lib/router", async () => { const actual = await vi.importActual("react-router-dom"); @@ -74,5 +75,83 @@ describe("AgentOverview", () => { expect(markup).toContain('href="/activity/costs?agentId=agent-1"'); expect(markup).not.toContain("Run Activity"); expect(markup).not.toContain("Tasks by Status"); + expect(markup).not.toContain("Created by"); + }); + + function renderOverview(createdByUserId: string | null, client: QueryClient) { + const agent = { + id: "agent-2", + companyId: "company-1", + name: "Shared Agent", + urlKey: "sharedagent", + role: "engineer", + title: null, + status: "idle", + reportsTo: null, + capabilities: null, + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + createdByUserId, + chainOfCommand: [], + access: { canAssignTasks: true, taskAssignSource: "explicit_grant", membership: null, grants: [] }, + } as unknown as AgentDetail; + return renderToStaticMarkup( + + + , + ); + } + + function renderWithDirectory(createdByUserId: string, principalIds: string[]) { + const client = new QueryClient(); + client.setQueryData(queryKeys.access.companyUserDirectory("company-1"), { + users: principalIds.map((principalId) => ({ + principalId, + status: "active", + user: { id: principalId, name: principalId === "user-alice" ? "Alice Admin" : "Bob Builder", email: null, image: null }, + })), + }); + return renderOverview(createdByUserId, client); + } + + it("shows who created the agent when the company has more than one human user", () => { + const markup = renderWithDirectory("user-alice", ["user-alice", "user-bob"]); + expect(markup).toContain("Created by"); + expect(markup).toContain("Alice Admin"); + }); + + it("hides the creator when only one human user shares the company with the Board", () => { + const markup = renderWithDirectory("user-alice", ["user-alice", "local-board"]); + expect(markup).not.toContain("Created by"); + }); + + function failedDirectoryClient() { + const client = new QueryClient({ defaultOptions: { queries: { retryOnMount: false } } }); + client.getQueryCache().build(client, { queryKey: queryKeys.access.companyUserDirectory("company-1") }).setState({ + status: "error", + error: new Error("Directory unavailable"), + fetchStatus: "idle", + }); + return client; + } + + it("shows an error when the company user directory cannot be loaded", () => { + const markup = renderOverview("user-alice", failedDirectoryClient()); + expect(markup).toContain("Created by"); + expect(markup).toContain("Could not load creator"); + }); + + it.each([null, "local-board"])("keeps the creator row hidden for creator %s when the directory failed", (createdByUserId) => { + const markup = renderOverview(createdByUserId, failedDirectoryClient()); + expect(markup).not.toContain("Created by"); + expect(markup).not.toContain("Could not load creator"); }); }); diff --git a/ui/storybook/stories/agents/agent-detail.stories.tsx b/ui/storybook/stories/agents/agent-detail.stories.tsx index 1b8faaf2612..b7754fd1eaa 100644 --- a/ui/storybook/stories/agents/agent-detail.stories.tsx +++ b/ui/storybook/stories/agents/agent-detail.stories.tsx @@ -129,11 +129,24 @@ const budgetOverviewFixture: BudgetOverview = { pendingApprovalCount: 0, }; -function seedAgentDetailData(queryClient: QueryClient) { +const sharedCompanyUserDirectoryFixture = { + users: [ + { principalId: "local-board", status: "active", user: { id: "local-board", email: null, name: "Board", image: null } }, + { principalId: "user-alice", status: "active", user: { id: "user-alice", email: "alice@example.com", name: "Alice Admin", image: null } }, + { principalId: "user-bob", status: "active", user: { id: "user-bob", email: "bob@example.com", name: "Bob Builder", image: null } }, + ], +}; + +type AgentDetailScenarioOptions = { sharedCompany?: boolean }; + +function seedAgentDetailData(queryClient: QueryClient, { sharedCompany = false }: AgentDetailScenarioOptions = {}) { queryClient.setQueryData( [...queryKeys.agents.detail(AGENT_ROUTE_REF), COMPANY_ID], - agentDetailFixture, + sharedCompany ? { ...agentDetailFixture, createdByUserId: "user-alice" } : agentDetailFixture, ); + if (sharedCompany) { + queryClient.setQueryData(queryKeys.access.companyUserDirectory(COMPANY_ID), sharedCompanyUserDirectoryFixture); + } queryClient.setQueryData(queryKeys.agents.runtimeState(AGENT_ID), runtimeStateFixture); queryClient.setQueryData(queryKeys.heartbeats(COMPANY_ID, AGENT_ID), heartbeatRunsFixture); queryClient.setQueryData( @@ -155,7 +168,7 @@ function seedAgentDetailData(queryClient: QueryClient) { * (fixed at /PAP/storybook): seed the QueryClient, then navigate to the * canonical agent URL so useParams resolves the fixture agent. */ -function AgentDetailScenario() { +function AgentDetailScenario(options: AgentDetailScenarioOptions) { const queryClient = useQueryClient(); const navigate = useNavigate(); const location = useLocation(); @@ -163,7 +176,7 @@ function AgentDetailScenario() { // Seed synchronously before the page's queries mount (staleTime: Infinity // in the preview QueryClient keeps these fixtures authoritative). useState(() => { - seedAgentDetailData(queryClient); + seedAgentDetailData(queryClient, options); return true; }); @@ -209,3 +222,7 @@ type Story = StoryObj; export const Dashboard: Story = { render: () => , }; + +export const SharedCompanyShowsCreator: Story = { + render: () => , +};