feat(employee): agent employees — employee.kind, employee_agent_profile, createAgentEmployee (G-P1-1)
What
G-P1-1 (core): agent employees. Part of the Gauzy ↔ Ever Works integration plan (docs/superpowers/specs/2026-10-15-gauzy-ever-works-integration/06-gauzy-implementation-plan.md, decision D2 "Agents = Employees with kind='agent'" in ever-co/homelab). Each Ever Works agent becomes a Gauzy employee, so it can be assigned to tasks, join teams and be mentioned with no further work.
Additive, and behaviour-neutral for people. Every existing and new row the employee routes create is human. Only server code can create an agent, through the integration (plugin lane G-P1-2/G-P1-6). Nothing calls that path while EVER_WORKS_ENABLED is off.
Builds on #10759 (contracts). Until #10759 merges, this diff also shows its commits.
employee.kind
- Column:
varchar(16) NOT NULL DEFAULT 'human', indexed with TypeORM's own nameIDX_bd5498b600e43bf3308fe5adc8. - Declared in: the entity,
IEmployee.kind, the GraphQLEmployee.kindtype andEmployeeFilter.kindfilter (plus the snapshot), and the resolver's filterable map. - Not writable:
kindis in no create or update DTO.EmployeeCreateHandler(RESTPOST /employee,/bulk, GraphQLcreateEmployee) pinskind = humanafter spreading the input, so a body cannot file an agent. - Migration
1791000000721-AddEmployeeKind(Postgres / SQLite / MySQL):- idempotent, with an advisory lock on Postgres;
- on Postgres 11+ a constant default is catalog-only;
downdrops the index and the column on Postgres and MySQL; SQLite keeps the column, by the platform's convention for additive columns on the embedded dialect.
Core module employee-agent-profile
- Table
employee_agent_profile:- one row per employee (the
REL_unique constraint onemployeeId); - deleted together with its employee (
ON DELETE CASCADE); reportsToEmployeeIdis cleared when that employee is deleted (SET NULL).
- one row per employee (the
- Columns, per plan 06:
source,externalId(indexed),slug,title,capabilities(JSON),status,scope,scopeTargetId,reportsToEmployeeId,modelId,avatarUrl,lastRunAt,managedExternally(default false),externalUrl,metadata(JSON). - Migration
1791000000722-CreateEmployeeAgentProfileTable(×3 dialects, guarded byhasTable,downdrops the table). Keys and indexes use TypeORM's default names. GET /employee/:id/agent-profile:- guarded by
ORG_EMPLOYEES_VIEWand scoped to the caller's tenant; - returns 404 for an employee without a profile;
- read-only: profiles are written by integrations only, through
EmployeeAgentProfileService.upsertForEmployee/writeAgentProfile; - the scope and identity fields (
id, tenant, organization, employee) are never taken from input.
- guarded by
GET /employee?kind=agent|humannarrows the list. Withoutkindthe list is unchanged. The GraphQL equivalent isemployees(filter: { kind: { eq: "agent" } }).
EmployeeService.createAgentEmployee(input)
One transaction creates the User, the Employee and (optionally) the profile.
- User:
hash = null, so password sign-in never matches it (AuthService.loginfiltershash IS NOT NULL);- an email under the RFC 2606
.invalidTLD:agent-<uuid>@agents.invalidby default, and any other domain is refused. Nothing (welcome, password reset, sign-in code) can be delivered to it, so nobody can take over an agent account through a mailbox; - the tenant's
EMPLOYEErole, and no organization membership.
- Employee:
kind = 'agent';isTrackingEnabled = false,allowScreenshotCapture = false;- no pay fields;
- optional
short_description.
- No welcome email and no invite: the service has no mail or invite dependency.
- Idempotent on the email: an existing agent employee in the organization is returned with
created: false. - Never turns a person's account into an agent: it refuses a person's employee, or a user with a password, with 409.
- Contracts:
IAgentEmployeeCreateInput,IAgentEmployee,IEmployeeAgentProfile,EmployeeAgentStatusEnum,EmployeeAgentSourceEnum.
Agent exclusions (payroll, statistics, seat counts, ever-stats, time-tracking prompts, invite lists) are the next PR, with the full list of queries touched.
Tests
employee.service.create-agent.spec.ts: user has no password and an.invalidemail; agent kind; tracking off; no pay fields; profile written in the same transaction and scoped by the employee row; idempotency; refuses a deliverable email, a missing name, another tenant's organization, a person's email, and a password user.employee-agent-profile.service.spec.ts: picks only the allowed fields; create vs update; a new profile needssource; refuses areportsTofrom another organization, or the agent itself.employee-agent-profile.controller.spec.ts: returns 200 or 404; permission metadata.agent-employee.spec.ts: the.invalidaddress rule.database/employee-agent-migrations.spec.ts: both migrations run for real on SQLite (better-sqlite3), covering the column/default/index, existing rows readinghuman, an idempotent re-run, uniqueness, cascade and SET NULL,down, and up again afterdown.- Postgres: both migrations' Postgres branch run against PGlite (Postgres 17) through a QueryRunner shim, all PASS:
- up, and an idempotent re-run;
employee.kindischaracter varying(16) NOT NULL DEFAULT 'human', and existing rows readhuman;- the index exists;
- the
REL_unique constraint refuses a second profile for an employee; - the defaults: generated id,
managedExternally=false,isActive=true; reportsTois set to null when that employee is deleted, and the profile is deleted with its employee;- 7 foreign keys;
downdrops the table and the column, and up runs again afterdown.
- MySQL: not run locally (no MySQL here). The statements follow the dialect patterns of the existing core migrations (
1790000010000,1791000000165): anINFORMATION_SCHEMAindex probe, because MySQL has noIF [NOT] EXISTSfor indexes, and inlineINDEX/UNIQUE INDEXinCREATE TABLE. - Jest (core, 290 tests across employee, employee-agent-profile, employee-statistics, stats, payroll-run, integration and shared/pipes): green locally. The run used a stale dependency tree on Node 24 with ts-jest diagnostics off;
tscfor core has no errors in the changed files. - The 30 node-only static gates of
static-checks.ymlpass locally (that workflow does not run on PRs into develop).
🤖 Generated with Claude Code
Summary by cubic
Implements agent employees (employee.kind, employee_agent_profile, createAgentEmployee) as the foundation for the Gauzy ↔ Ever Works integration: an employee row is now a person (human, the default) or an agent (agent). Agents are provisioned with no password, no welcome email, and tracking off; all existing employee routes, lists, and invites behave exactly as before.
- New
employee.kindcolumn (default'human', indexed) andemployee_agent_profiletable (one row per agent), with idempotent migrations for Postgres, SQLite, and MySQL; MySQL runs check-then-create under a named lock (GET_LOCK) and creates the table with all its foreign keys in one statement. EmployeeService.createAgentEmployeecreates the user and employee in one transaction, serialized by a tenant-row lock without eager joins; it reuses only an existing agent account (never a person's or password user's), refuses soft-deleted agents with 409, and is idempotent on its reserved.invalidemail. A reused account without a role gets the tenant'sEMPLOYEErole.POST /employeerefuses (409) an account behind an agent employee, by email and by userId.- New read-only
GET /employee/:id/agent-profileand an optionalkindfilter onGET /employee(?kind=agent|human) and on the GraphQLEmployeeFilter;kindis pinned tohumanon every existing create path. Profile writes take the locked employee row as source of truth and return 404 for a missing row or 400 for a person.