feat(employee): agent employees — employee.kind, employee_agent_profile, createAgentEmployee (G-P1-1)

Merged
EV
11d ago
+2343-8

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 name IDX_bd5498b600e43bf3308fe5adc8.
  • Declared in: the entity, IEmployee.kind, the GraphQL Employee.kind type and EmployeeFilter.kind filter (plus the snapshot), and the resolver's filterable map.
  • Not writable: kind is in no create or update DTO. EmployeeCreateHandler (REST POST /employee, /bulk, GraphQL createEmployee) pins kind = human after 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;
    • down drops 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 on employeeId);
    • deleted together with its employee (ON DELETE CASCADE);
    • reportsToEmployeeId is cleared when that employee is deleted (SET NULL).
  • 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 by hasTable, down drops the table). Keys and indexes use TypeORM's default names.
  • GET /employee/:id/agent-profile:
    • guarded by ORG_EMPLOYEES_VIEW and 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.
  • GET /employee?kind=agent|human narrows the list. Without kind the list is unchanged. The GraphQL equivalent is employees(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.login filters hash IS NOT NULL);
    • an email under the RFC 2606 .invalid TLD: agent-<uuid>@agents.invalid by 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 EMPLOYEE role, 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 .invalid email; 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 needs source; refuses a reportsTo from another organization, or the agent itself.
  • employee-agent-profile.controller.spec.ts: returns 200 or 404; permission metadata.
  • agent-employee.spec.ts: the .invalid address rule.
  • database/employee-agent-migrations.spec.ts: both migrations run for real on SQLite (better-sqlite3), covering the column/default/index, existing rows reading human, an idempotent re-run, uniqueness, cascade and SET NULL, down, and up again after down.
  • Postgres: both migrations' Postgres branch run against PGlite (Postgres 17) through a QueryRunner shim, all PASS:
    • up, and an idempotent re-run;
    • employee.kind is character varying(16) NOT NULL DEFAULT 'human', and existing rows read human;
    • the index exists;
    • the REL_ unique constraint refuses a second profile for an employee;
    • the defaults: generated id, managedExternally=false, isActive=true;
    • reportsTo is set to null when that employee is deleted, and the profile is deleted with its employee;
    • 7 foreign keys;
    • down drops the table and the column, and up runs again after down.
  • MySQL: not run locally (no MySQL here). The statements follow the dialect patterns of the existing core migrations (1790000010000, 1791000000165): an INFORMATION_SCHEMA index probe, because MySQL has no IF [NOT] EXISTS for indexes, and inline INDEX / UNIQUE INDEX in CREATE 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; tsc for core has no errors in the changed files.
  • The 30 node-only static gates of static-checks.yml pass 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.kind column (default 'human', indexed) and employee_agent_profile table (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.createAgentEmployee creates 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 .invalid email. A reused account without a role gets the tenant's EMPLOYEE role.
  • POST /employee refuses (409) an account behind an agent employee, by email and by userId.
  • New read-only GET /employee/:id/agent-profile and an optional kind filter on GET /employee (?kind=agent|human) and on the GraphQL EmployeeFilter; kind is pinned to human on 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.