From 692827edc134cb69a61ef1d81b6d180bfd3776ff Mon Sep 17 00:00:00 2001 From: aaight Date: Wed, 24 Jun 2026 16:23:09 +0200 Subject: [PATCH 01/18] feat(queue): add durable debug-analysis job-state helpers to dashboard queue client (#1438) Co-authored-by: Cascade Bot --- src/queue/client.ts | 54 +++++++++++++++++++- tests/unit/queue/client.test.ts | 87 +++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 1 deletion(-) diff --git a/src/queue/client.ts b/src/queue/client.ts index 3abe14427..a3567f116 100644 --- a/src/queue/client.ts +++ b/src/queue/client.ts @@ -5,7 +5,7 @@ * Only loaded when REDIS_URL is set (production dashboard container). */ -import { Queue } from 'bullmq'; +import { type JobState, Queue } from 'bullmq'; import { parseRedisUrl } from '../utils/redis.js'; // ── Job types ──────────────────────────────────────────────────────────────── @@ -79,3 +79,55 @@ export async function submitDashboardJob(job: DashboardJob, jobId?: string): Pro const result = await getQueue().add(job.type, job, { jobId: id }); return result.id ?? id; } + +// ── Debug-analysis job-state helpers ─────────────────────────────────────────── +// +// These let the dashboard process (where the API runs) observe running/queued/ +// failed state for a debug-analysis job cross-process, using the BullMQ queue as +// the source of truth rather than the worker-local in-memory Set in +// `src/triggers/shared/debug-status.ts`. Pairing a deterministic job id (one job +// per analyzed run) with state read + idempotent removal lets a re-run reuse the +// same id without a stale completed/failed job blocking the add. + +/** + * Deterministic BullMQ job id for the debug-analysis job of a given run. + * + * One job per analyzed run: passing this id to {@link submitDashboardJob} makes + * the queue self-deduplicating for `debug-analysis` and lets other processes + * resolve the job's state by run id alone. + */ +export function debugAnalysisJobId(runId: string): string { + return `debug-analysis-${runId}`; +} + +/** + * Resolve the BullMQ state of a dashboard job by id. + * + * Returns the job's state (`waiting | active | delayed | prioritized | + * waiting-children | completed | failed | unknown`) or `null` when no job with + * that id exists in the queue. + */ +export async function getDashboardJobState(jobId: string): Promise { + const job = await getQueue().getJob(jobId); + if (!job) { + return null; + } + return job.getState(); +} + +/** + * Remove a dashboard job by id. + * + * Wrapped in try/catch so it is a safe no-op when the job is absent or locked + * (e.g. currently active in a worker). This lets a re-run reuse the same + * deterministic id (see {@link debugAnalysisJobId}) without a stale + * completed/failed job blocking a subsequent {@link submitDashboardJob}. + */ +export async function removeDashboardJob(jobId: string): Promise { + try { + await getQueue().remove(jobId); + } catch { + // Safe no-op: the job may be absent or locked (active). Removal failures + // must not surface to the caller — the next add will reuse the id. + } +} diff --git a/tests/unit/queue/client.test.ts b/tests/unit/queue/client.test.ts index 67e8018f6..6f3905abc 100644 --- a/tests/unit/queue/client.test.ts +++ b/tests/unit/queue/client.test.ts @@ -8,10 +8,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // ── Mocks (must be set up before dynamic import) ────────────────────────────── const mockQueueAdd = vi.fn(); +const mockQueueGetJob = vi.fn(); +const mockQueueRemove = vi.fn(); vi.mock('bullmq', () => ({ Queue: vi.fn().mockImplementation(() => ({ add: mockQueueAdd, + getJob: mockQueueGetJob, + remove: mockQueueRemove, })), })); @@ -35,6 +39,8 @@ async function freshImport() { vi.mock('bullmq', () => ({ Queue: vi.fn().mockImplementation(() => ({ add: mockQueueAdd, + getJob: mockQueueGetJob, + remove: mockQueueRemove, })), })); vi.mock('../../../src/utils/redis.js', () => ({ @@ -186,3 +192,84 @@ describe('getQueue error handling', () => { } }); }); + +describe('debugAnalysisJobId', () => { + afterEach(() => { + vi.resetModules(); + }); + + it('returns the deterministic id prefixed with debug-analysis- for a runId', async () => { + const { debugAnalysisJobId } = await freshImport(); + + expect(debugAnalysisJobId('run-123')).toBe('debug-analysis-run-123'); + }); + + it('produces the same id for the same run (one job per analyzed run)', async () => { + const { debugAnalysisJobId } = await freshImport(); + + expect(debugAnalysisJobId('abc')).toBe('debug-analysis-abc'); + expect(debugAnalysisJobId('abc')).toBe(debugAnalysisJobId('abc')); + }); +}); + +describe('getDashboardJobState', () => { + beforeEach(() => { + vi.stubEnv('REDIS_URL', 'redis://localhost:6379'); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it('resolves the job by id and returns its BullMQ state', async () => { + const getState = vi.fn().mockResolvedValue('active'); + mockQueueGetJob.mockResolvedValue({ getState }); + const { getDashboardJobState } = await freshImport(); + + const state = await getDashboardJobState('debug-analysis-run-1'); + + expect(mockQueueGetJob).toHaveBeenCalledWith('debug-analysis-run-1'); + expect(getState).toHaveBeenCalledTimes(1); + expect(state).toBe('active'); + }); + + it('returns null when the job is absent', async () => { + mockQueueGetJob.mockResolvedValue(undefined); + const { getDashboardJobState } = await freshImport(); + + const state = await getDashboardJobState('debug-analysis-missing'); + + expect(mockQueueGetJob).toHaveBeenCalledWith('debug-analysis-missing'); + expect(state).toBeNull(); + }); +}); + +describe('removeDashboardJob', () => { + beforeEach(() => { + vi.stubEnv('REDIS_URL', 'redis://localhost:6379'); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it('removes the job by id', async () => { + mockQueueRemove.mockResolvedValue(1); + const { removeDashboardJob } = await freshImport(); + + await removeDashboardJob('debug-analysis-run-1'); + + expect(mockQueueRemove).toHaveBeenCalledWith('debug-analysis-run-1'); + }); + + it('swallows errors when the job is absent or locked', async () => { + mockQueueRemove.mockRejectedValue(new Error('job is locked')); + const { removeDashboardJob } = await freshImport(); + + // Must resolve (no throw) even though the underlying remove rejected. + await expect(removeDashboardJob('locked-job')).resolves.toBeUndefined(); + expect(mockQueueRemove).toHaveBeenCalledWith('locked-job'); + }); +}); From 8304127d8baa4bb1a6e6af7989349cd0c1aaac1b Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Wed, 24 Jun 2026 14:43:56 +0000 Subject: [PATCH 02/18] feat(db): add org_memberships table + sessions.active_org_id (spec 021 plan 1) Introduce the multi-org membership data model (spec 021, plan 1 of 4) so one account can belong to many orgs: - org_memberships table: user <-> org link with a per-org role; at most one membership per (user, org) - sessions.active_org_id column: nullable, ON DELETE SET NULL so deleting an org never logs a user out - forward-only migration 0053 that backfills exactly one membership per existing user from their home org (users.org_id) + role, mapping the global superadmin role to an admin membership Ships dormant: nothing reads the new table/column yet (plan 2 wires resolution). users.org_id / users.role are retained as the home org + global role. Satisfies spec AC #6. Co-Authored-By: Claude Opus 4.8 --- docs/architecture/09-database.md | 14 +- src/db/migrations/0053_org_memberships.sql | 54 ++++++ src/db/migrations/meta/_journal.json | 7 + src/db/schema/index.ts | 1 + src/db/schema/orgMemberships.ts | 40 ++++ src/db/schema/users.ts | 9 + .../db/orgMembershipsBackfill.test.ts | 183 ++++++++++++++++++ tests/integration/helpers/db.ts | 1 + 8 files changed, 307 insertions(+), 2 deletions(-) create mode 100644 src/db/migrations/0053_org_memberships.sql create mode 100644 src/db/schema/orgMemberships.ts create mode 100644 tests/integration/db/orgMembershipsBackfill.test.ts diff --git a/docs/architecture/09-database.md b/docs/architecture/09-database.md index 9bd92f08c..272abd2b8 100644 --- a/docs/architecture/09-database.md +++ b/docs/architecture/09-database.md @@ -10,6 +10,7 @@ CASCADE uses PostgreSQL with [Drizzle ORM](https://orm.drizzle.team/) for type-s erDiagram organizations ||--o{ projects : "has" organizations ||--o{ users : "has" + organizations ||--o{ org_memberships : "has" organizations ||--o{ prompt_partials : "has" projects ||--o{ project_integrations : "has" @@ -25,6 +26,7 @@ erDiagram agent_runs ||--o| debug_analyses : "analyzed by" users ||--o{ sessions : "has" + users ||--o{ org_memberships : "has" organizations { text id PK @@ -32,6 +34,13 @@ erDiagram jsonb settings } + org_memberships { + uuid id PK + uuid user_id FK + text org_id FK + text role + } + projects { text id PK text org_id FK @@ -137,8 +146,9 @@ erDiagram | `prompt_partials` | Org-scoped prompt template customizations | UNIQUE(`org_id`, `name`) | | `pr_work_items` | Maps PRs and external alert sources to PM work items for run-link display and alert idempotency | Partial unique indexes on `(project_id, pr_number)`, `(project_id, work_item_id)`, and `(project_id, external_source, external_id)` when those values are present | | `webhook_logs` | Raw webhook payloads for debugging | — | -| `users` | Dashboard users (email, bcrypt hash, role) | Org-scoped | -| `sessions` | Session tokens for cookie auth (30-day expiry) | — | +| `users` | Dashboard users (email, bcrypt hash, role) | Org-scoped (`org_id` = home org, `role` = global role) | +| `org_memberships` | Multi-org membership: links a user to an org with a per-org role, so one account can belong to many orgs. `users.org_id`/`users.role` remain the home org + global role. Ships dormant (spec 021 plan 1) — read by plan 2. | UNIQUE(`user_id`, `org_id`) | +| `sessions` | Session tokens for cookie auth (30-day expiry); `active_org_id` (nullable) tracks the org the session is currently acting in for multi-org | — | | `debug_analyses` | AI debug analysis results | — | ## Repositories diff --git a/src/db/migrations/0053_org_memberships.sql b/src/db/migrations/0053_org_memberships.sql new file mode 100644 index 000000000..e96e8a270 --- /dev/null +++ b/src/db/migrations/0053_org_memberships.sql @@ -0,0 +1,54 @@ +-- 0053_org_memberships.sql +-- Multi-org membership (spec 021, plan 1 of 4): introduce the data model so one +-- account can belong to many orgs. +-- +-- Adds: +-- * org_memberships — user <-> org link with a per-org role. +-- * sessions.active_org_id — the org a session is currently acting in. +-- +-- Backfills exactly one membership per existing user from their home org +-- (users.org_id) and role, mapping the global 'superadmin' role to an 'admin' +-- membership (membership roles are per-org; 'superadmin' is a global role). +-- +-- Ships DORMANT: nothing reads the new table/column yet (plan 2 wires +-- resolution). users.org_id / users.role are retained as the home/primary org +-- and global role. Forward-only — no down migration. + +BEGIN; + +-- 1. org_memberships: a user's membership in an org, with a per-org role. +CREATE TABLE IF NOT EXISTS org_memberships ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + org_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'member', + created_at TIMESTAMP DEFAULT now(), + updated_at TIMESTAMP DEFAULT now() +); + +-- At most one membership per (user, org). +CREATE UNIQUE INDEX IF NOT EXISTS uq_org_memberships_user_org + ON org_memberships(user_id, org_id); + +-- Resolution lookups (plan 2) and per-org member listing. +CREATE INDEX IF NOT EXISTS idx_org_memberships_user_id ON org_memberships(user_id); +CREATE INDEX IF NOT EXISTS idx_org_memberships_org_id ON org_memberships(org_id); + +-- 2. sessions.active_org_id: which org this session is currently acting in. +-- Nullable; ON DELETE SET NULL so deleting an org never logs a user out — +-- plan 2's resolver falls back to the user's home org when this is NULL. +ALTER TABLE sessions ADD COLUMN IF NOT EXISTS active_org_id TEXT + REFERENCES organizations(id) ON DELETE SET NULL; + +-- 3. Backfill exactly one membership per existing user from their home org + +-- role. Map the global 'superadmin' role to an 'admin' membership. +-- Idempotent via ON CONFLICT on the (user_id, org_id) unique index. +INSERT INTO org_memberships (user_id, org_id, role) +SELECT + u.id, + u.org_id, + CASE WHEN u.role = 'superadmin' THEN 'admin' ELSE u.role END +FROM users u +ON CONFLICT (user_id, org_id) DO NOTHING; + +COMMIT; diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 68ee1526c..c134f1f2e 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -372,6 +372,13 @@ "when": 1787000000000, "tag": "0052_workflow_status_definitions", "breakpoints": false + }, + { + "idx": 53, + "version": "7", + "when": 1788000000000, + "tag": "0053_org_memberships", + "breakpoints": false } ] } diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index 43cc428b8..5f171b448 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -3,6 +3,7 @@ export { agentDefinitions } from './agentDefinitions.js'; export { agentTriggerConfigs } from './agentTriggerConfigs.js'; export { projectIntegrations } from './integrations.js'; export { organizations } from './organizations.js'; +export { orgMemberships } from './orgMemberships.js'; export { projectCredentials } from './projectCredentials.js'; export { projects } from './projects.js'; export { promptPartials } from './promptPartials.js'; diff --git a/src/db/schema/orgMemberships.ts b/src/db/schema/orgMemberships.ts new file mode 100644 index 000000000..e364dd9be --- /dev/null +++ b/src/db/schema/orgMemberships.ts @@ -0,0 +1,40 @@ +import { index, pgTable, text, timestamp, uniqueIndex, uuid } from 'drizzle-orm/pg-core'; +import { organizations } from './organizations.js'; +import { users } from './users.js'; + +/** + * Multi-org membership (spec 021): a user account's membership in an + * organization, with a per-org role. One account can belong to many orgs. + * + * This is distinct from `users.org_id` / `users.role`, which remain the user's + * home/primary org and global role. A user has at most one membership per org + * (enforced by the `(user_id, org_id)` unique index). + * + * Ships dormant in plan 1 (schema only) — nothing reads this table until plan 2 + * wires active-org resolution. + */ +export const orgMemberships = pgTable( + 'org_memberships', + { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + orgId: text('org_id') + .notNull() + .references(() => organizations.id, { onDelete: 'cascade' }), + /** Per-org role (e.g. 'member' | 'admin'). Distinct from the global `users.role`. */ + role: text('role').notNull().default('member'), + createdAt: timestamp('created_at').defaultNow(), + updatedAt: timestamp('updated_at') + .defaultNow() + .$onUpdate(() => new Date()), + }, + (table) => [ + // One membership per (user, org). + uniqueIndex('uq_org_memberships_user_org').on(table.userId, table.orgId), + // Resolution lookups (plan 2) and per-org member listing. + index('idx_org_memberships_user_id').on(table.userId), + index('idx_org_memberships_org_id').on(table.orgId), + ], +); diff --git a/src/db/schema/users.ts b/src/db/schema/users.ts index d2988c2ce..8d7768204 100644 --- a/src/db/schema/users.ts +++ b/src/db/schema/users.ts @@ -30,6 +30,15 @@ export const sessions = pgTable( token: text('token').notNull().unique(), expiresAt: timestamp('expires_at').notNull(), createdAt: timestamp('created_at').defaultNow(), + /** + * Multi-org membership (spec 021): the org this session is currently + * acting in. Nullable; `ON DELETE SET NULL` so deleting an org never logs + * a user out. Ships dormant in plan 1 — plan 2's resolver falls back to + * the user's home org (`users.org_id`) when this is NULL. + */ + activeOrgId: text('active_org_id').references(() => organizations.id, { + onDelete: 'set null', + }), }, (table) => [ index('idx_sessions_token').on(table.token), diff --git a/tests/integration/db/orgMembershipsBackfill.test.ts b/tests/integration/db/orgMembershipsBackfill.test.ts new file mode 100644 index 000000000..84f21695d --- /dev/null +++ b/tests/integration/db/orgMembershipsBackfill.test.ts @@ -0,0 +1,183 @@ +/** + * Integration test for migration 0053: multi-org membership schema + * (spec 021, plan 1 of 4). + * + * Verifies the dormant schema additions and the one-membership-per-user + * backfill: + * - the org_memberships table exists and is selectable + * - sessions.active_org_id is a nullable, NULL-by-default column + * - every existing user gets exactly one membership in their home org + * - the global 'superadmin' role maps to an 'admin' membership + * - users.org_id / users.role are left untouched (home org + global role) + * - the backfill is idempotent and active_org_id stays dormant (NULL) + * + * The migration is idempotent and already ran at test bootstrap; this test + * seeds rows that look like pre-migration state, re-runs the migration body, and + * asserts the resulting memberships. + */ +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { eq, sql } from 'drizzle-orm'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { getDb } from '../../../src/db/client.js'; +import { orgMemberships, sessions, users } from '../../../src/db/schema/index.js'; +import { truncateAll } from '../helpers/db.js'; +import { seedOrg, seedSession, seedUser } from '../helpers/seed.js'; + +const MIGRATION_PATH = fileURLToPath( + new URL('../../../src/db/migrations/0053_org_memberships.sql', import.meta.url), +); + +/** + * Re-runs the migration body minus its transaction wrappers (drizzle's raw sql + * tag runs inside its own connection). The migration is fully idempotent, so + * re-running after seeding users is safe. + */ +async function runMigration(): Promise { + const migrationText = await readFile(MIGRATION_PATH, 'utf-8'); + const body = migrationText + .split('\n') + .filter((line) => !/^\s*(BEGIN|COMMIT)\s*;\s*$/i.test(line)) + .join('\n'); + await getDb().execute(sql.raw(body)); +} + +async function listMemberships() { + return getDb() + .select({ + userId: orgMemberships.userId, + orgId: orgMemberships.orgId, + role: orgMemberships.role, + }) + .from(orgMemberships); +} + +describe('migration 0053 — org_memberships table + active-org + backfill', () => { + beforeEach(async () => { + await truncateAll(); + await seedOrg('test-org', 'Test Org'); + }); + + describe('schema', () => { + it('creates the org_memberships table (selectable, empty after truncate)', async () => { + expect(await listMemberships()).toEqual([]); + }); + + it('exposes a nullable sessions.active_org_id (NULL by default)', async () => { + const user = await seedUser({ email: 'dormant@example.com' }); + // seedSession never sets active_org_id — a successful insert proves the + // column is nullable with no NOT NULL constraint. + await seedSession({ userId: user.id, token: 'dormant-token' }); + + const [row] = await getDb() + .select({ activeOrgId: sessions.activeOrgId }) + .from(sessions) + .where(eq(sessions.token, 'dormant-token')); + expect(row.activeOrgId).toBeNull(); + }); + }); + + describe('backfill', () => { + it('creates exactly one membership per existing user', async () => { + await seedUser({ email: 'a@example.com', role: 'member' }); + await seedUser({ email: 'b@example.com', role: 'admin' }); + await seedUser({ email: 'c@example.com', role: 'member' }); + + await runMigration(); + + const memberships = await listMemberships(); + expect(memberships).toHaveLength(3); + expect(new Set(memberships.map((m) => m.userId)).size).toBe(3); + }); + + it('copies the home org and member/admin role verbatim', async () => { + const member = await seedUser({ email: 'm@example.com', role: 'member' }); + const admin = await seedUser({ email: 'adm@example.com', role: 'admin' }); + + await runMigration(); + + const byUser = new Map((await listMemberships()).map((m) => [m.userId, m])); + expect(byUser.get(member.id)).toMatchObject({ orgId: 'test-org', role: 'member' }); + expect(byUser.get(admin.id)).toMatchObject({ orgId: 'test-org', role: 'admin' }); + }); + + it('maps the global superadmin role to an admin membership', async () => { + const sa = await seedUser({ email: 'super@example.com', role: 'superadmin' }); + + await runMigration(); + + const [membership] = await listMemberships(); + expect(membership).toMatchObject({ userId: sa.id, orgId: 'test-org', role: 'admin' }); + + // users.role stays the global superadmin role — kept, not rewritten. + const [userRow] = await getDb() + .select({ role: users.role }) + .from(users) + .where(eq(users.id, sa.id)); + expect(userRow.role).toBe('superadmin'); + }); + + it('scopes each membership to the user home org across multiple orgs', async () => { + await seedOrg('org-2', 'Second Org'); + const u1 = await seedUser({ email: 'one@example.com', orgId: 'test-org', role: 'member' }); + const u2 = await seedUser({ email: 'two@example.com', orgId: 'org-2', role: 'admin' }); + + await runMigration(); + + const byUser = new Map((await listMemberships()).map((m) => [m.userId, m])); + expect(byUser.get(u1.id)?.orgId).toBe('test-org'); + expect(byUser.get(u2.id)?.orgId).toBe('org-2'); + }); + + it('is idempotent: re-running adds no duplicate memberships', async () => { + await seedUser({ email: 'idem@example.com', role: 'member' }); + + await runMigration(); + const first = await listMemberships(); + + await runMigration(); + const second = await listMemberships(); + + expect(first).toHaveLength(1); + expect(second).toHaveLength(1); + }); + + it('leaves active_org_id dormant (NULL) on existing sessions', async () => { + const user = await seedUser({ email: 'still-logged-in@example.com', role: 'member' }); + await seedSession({ userId: user.id, token: 'pre-migration-token' }); + + await runMigration(); + + const [row] = await getDb() + .select({ activeOrgId: sessions.activeOrgId }) + .from(sessions) + .where(eq(sessions.token, 'pre-migration-token')); + // Session survives the migration (user stays logged in) and active_org_id + // is untouched — plan 2 resolves it; plan 1 ships dormant. + expect(row.activeOrgId).toBeNull(); + }); + }); + + describe('constraints', () => { + it('cascade-deletes memberships when the user is deleted', async () => { + const user = await seedUser({ email: 'todelete@example.com', role: 'member' }); + await runMigration(); + expect(await listMemberships()).toHaveLength(1); + + await getDb().delete(users).where(eq(users.id, user.id)); + + expect(await listMemberships()).toHaveLength(0); + }); + + it('rejects a second membership for the same (user, org)', async () => { + const user = await seedUser({ email: 'dup@example.com', role: 'member' }); + await runMigration(); + + await expect( + getDb() + .insert(orgMemberships) + .values({ userId: user.id, orgId: 'test-org', role: 'admin' }), + ).rejects.toThrow(); + }); + }); +}); diff --git a/tests/integration/helpers/db.ts b/tests/integration/helpers/db.ts index ddef06435..3e1745090 100644 --- a/tests/integration/helpers/db.ts +++ b/tests/integration/helpers/db.ts @@ -123,6 +123,7 @@ export async function truncateAll() { agent_definitions, workflow_status_definitions, prompt_partials, + org_memberships, sessions, users, projects, From 98d68d993b87a7a35eda78e471278ee1f996a5d1 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Wed, 24 Jun 2026 16:20:09 +0000 Subject: [PATCH 03/18] feat(auth): membership-aware org resolution, active-org switching, per-org roles (spec 021 plan 2) --- docs/architecture/01-services.md | 7 +- docs/architecture/09-database.md | 2 +- src/api/auth/session.ts | 17 +- src/api/context.ts | 68 +++++++- src/api/routers/auth.ts | 43 ++++- src/api/routers/users.ts | 51 +++++- src/dashboard.ts | 9 +- .../repositories/orgMembershipsRepository.ts | 59 +++++++ src/db/repositories/usersRepository.ts | 19 +++ .../db/orgMembershipsAccess.test.ts | 151 ++++++++++++++++++ tests/integration/helpers/seed.ts | 26 ++- tests/unit/api/access-control.test.ts | 130 ++++++++++++++- tests/unit/api/auth/session.test.ts | 35 ++-- tests/unit/api/routers/auth.test.ts | 108 ++++++++++++- tests/unit/api/routers/users.test.ts | 85 ++++++++++ .../orgMembershipsRepository.test.ts | 68 ++++++++ .../db/repositories/usersRepository.test.ts | 21 +++ 17 files changed, 865 insertions(+), 34 deletions(-) create mode 100644 src/db/repositories/orgMembershipsRepository.ts create mode 100644 tests/integration/db/orgMembershipsAccess.test.ts create mode 100644 tests/unit/db/repositories/orgMembershipsRepository.test.ts diff --git a/docs/architecture/01-services.md b/docs/architecture/01-services.md index 9ee911e60..dff020ba6 100644 --- a/docs/architecture/01-services.md +++ b/docs/architecture/01-services.md @@ -172,7 +172,8 @@ Module-load phase (runs at import time, before `startDashboard()`): ### tRPC context Every tRPC request builds a context containing: -- `user` — resolved from session cookie via `resolveUserFromSession()` -- `effectiveOrgId` — computed from user's org membership or `x-org-context` header +- `user` — resolved from session cookie via `resolveUserFromSession()` (also returns the session's `active_org_id`) +- `effectiveOrgId` — computed by `computeEffectiveOrgId()`. For a **superadmin** the `x-org-context` header selects any org (unchanged). For everyone else the session's `active_org_id` governs, validated against `org_memberships`, and falls back to the user's home org (`users.org_id`) when there is no active org or the membership is gone — so deleting an org / losing a membership never logs a user out (spec 021 plan 2). +- `token` — the session token, used by `auth.setActiveOrg` to switch the session's active org -Procedure types enforce auth levels: `publicProcedure`, `protectedProcedure`, `adminProcedure`, `superAdminProcedure`. +Procedure types enforce auth levels: `publicProcedure`, `protectedProcedure`, `adminProcedure`, `superAdminProcedure`. User-management permission checks additionally consume `resolveActorRoleInOrg()` so the caller's **per-org** membership role — not the global `users.role` — governs (an org admin who switches into an org where they are only a member cannot act as an admin there). diff --git a/docs/architecture/09-database.md b/docs/architecture/09-database.md index 272abd2b8..54ea61947 100644 --- a/docs/architecture/09-database.md +++ b/docs/architecture/09-database.md @@ -147,7 +147,7 @@ erDiagram | `pr_work_items` | Maps PRs and external alert sources to PM work items for run-link display and alert idempotency | Partial unique indexes on `(project_id, pr_number)`, `(project_id, work_item_id)`, and `(project_id, external_source, external_id)` when those values are present | | `webhook_logs` | Raw webhook payloads for debugging | — | | `users` | Dashboard users (email, bcrypt hash, role) | Org-scoped (`org_id` = home org, `role` = global role) | -| `org_memberships` | Multi-org membership: links a user to an org with a per-org role, so one account can belong to many orgs. `users.org_id`/`users.role` remain the home org + global role. Ships dormant (spec 021 plan 1) — read by plan 2. | UNIQUE(`user_id`, `org_id`) | +| `org_memberships` | Multi-org membership: links a user to an org with a per-org role, so one account can belong to many orgs. `users.org_id`/`users.role` remain the home org + global role. Read by effective-org resolution + the per-org actor-role helper (spec 021 plan 2). | UNIQUE(`user_id`, `org_id`) | | `sessions` | Session tokens for cookie auth (30-day expiry); `active_org_id` (nullable) tracks the org the session is currently acting in for multi-org | — | | `debug_analyses` | AI debug analysis results | — | diff --git a/src/api/auth/session.ts b/src/api/auth/session.ts index a54bff45c..25cda3ce0 100644 --- a/src/api/auth/session.ts +++ b/src/api/auth/session.ts @@ -4,8 +4,21 @@ import { getUserById, } from '../../db/repositories/usersRepository.js'; -export async function resolveUserFromSession(token: string): Promise { +/** The user behind a session token plus the session's active-org pointer. */ +export interface ResolvedSession { + user: DashboardUser; + /** + * The org this session is currently acting in (spec 021 plan 2). `null` when + * the session predates a switch or the org was deleted — effective-org + * resolution falls back to the user's home org so nobody is logged out. + */ + activeOrgId: string | null; +} + +export async function resolveUserFromSession(token: string): Promise { const session = await getSessionByToken(token); if (!session) return null; - return getUserById(session.userId); + const user = await getUserById(session.userId); + if (!user) return null; + return { user, activeOrgId: session.activeOrgId ?? null }; } diff --git a/src/api/context.ts b/src/api/context.ts index 787539bbe..021a49b66 100644 --- a/src/api/context.ts +++ b/src/api/context.ts @@ -1,14 +1,76 @@ +import { getOrgMembership } from '../db/repositories/orgMembershipsRepository.js'; import { getOrganization } from '../db/repositories/settingsRepository.js'; import type { TRPCUser } from './trpc.js'; +/** Role used for permission evaluation. Same union as `TRPCUser['role']`. */ +export type ActorRole = TRPCUser['role']; + +/** + * Resolve the org a request acts in (spec 021 plan 2). + * + * Two distinct paths preserve the existing superadmin contract while moving + * regular users onto the membership model: + * + * - **Superadmin** (unchanged — spec AC #7): the `x-org-context` header selects + * any existing org. `active_org_id` and membership do not apply to the global + * superadmin role. + * - **Everyone else**: the session's `active_org_id` governs, validated against + * the user's membership. It falls back to the home org (`users.org_id`) when + * there is no active org or the membership no longer exists — the no-logout + * guarantee (deleting an org / losing a membership never logs a user out). + */ export async function computeEffectiveOrgId( user: TRPCUser | null, requestedOrgId: string | undefined, + activeOrgId?: string | null, ): Promise { if (!user) return null; - if (requestedOrgId && requestedOrgId !== user.orgId && user.role === 'superadmin') { - const org = await getOrganization(requestedOrgId); - return org ? requestedOrgId : user.orgId; + + if (user.role === 'superadmin') { + if (requestedOrgId && requestedOrgId !== user.orgId) { + const org = await getOrganization(requestedOrgId); + return org ? requestedOrgId : user.orgId; + } + return user.orgId; + } + + // Non-superadmin: the active org only takes effect when it differs from the + // home org and the user still has a membership there. The home org is always + // valid, so it needs no lookup and is the universal fallback. + if (activeOrgId && activeOrgId !== user.orgId) { + const membership = await getOrgMembership(user.id, activeOrgId); + if (membership) return activeOrgId; } return user.orgId; } + +/** + * Resolve a user's effective role *in a specific org* (spec 021 plan 2). + * + * Per-org membership — not the global `users.role` — governs permissions + * (spec AC #4), so an org admin who switches to an org where they are only a + * member cannot act as an admin there (spec AC #8). + * + * - **Superadmin** is a global role (spec AC #7): always `'superadmin'`, + * regardless of membership. + * - Otherwise the per-org membership role wins. + * - With no membership row, acting in the **home org** falls back to the global + * role (no-logout guard / pre-backfill safety); any other org gets least + * privilege (`'member'`). + */ +export async function resolveActorRoleInOrg(params: { + userId: string; + globalRole: ActorRole; + homeOrgId: string; + orgId: string; +}): Promise { + if (params.globalRole === 'superadmin') return 'superadmin'; + + const membership = await getOrgMembership(params.userId, params.orgId); + if (membership) { + return membership.role === 'admin' ? 'admin' : 'member'; + } + + if (params.orgId === params.homeOrgId) return params.globalRole; + return 'member'; +} diff --git a/src/api/routers/auth.ts b/src/api/routers/auth.ts index d951efcc3..60c764d88 100644 --- a/src/api/routers/auth.ts +++ b/src/api/routers/auth.ts @@ -1,7 +1,16 @@ +import { TRPCError } from '@trpc/server'; import bcrypt from 'bcrypt'; import { z } from 'zod'; +import { + getOrgMembership, + listOrgMembershipsForUser, +} from '../../db/repositories/orgMembershipsRepository.js'; import { getOrganization, listAllOrganizations } from '../../db/repositories/settingsRepository.js'; -import { deleteUserSessions, updateUser } from '../../db/repositories/usersRepository.js'; +import { + deleteUserSessions, + setSessionActiveOrg, + updateUser, +} from '../../db/repositories/usersRepository.js'; import { protectedProcedure, router } from '../trpc.js'; export const authRouter = router({ @@ -34,4 +43,36 @@ export const authRouter = router({ await updateUser(ctx.user.id, { passwordHash }); await deleteUserSessions(ctx.user.id, ctx.token || undefined); }), + + /** + * List the orgs the current user belongs to (spec 021 plan 2), with the + * user's per-org role. Drives the active-org switcher (UI lands in plan 4). + * Superadmins still discover all orgs via `me.availableOrgs`. + */ + listMyOrgs: protectedProcedure.query(async ({ ctx }) => { + return listOrgMembershipsForUser(ctx.user.id); + }), + + /** + * Switch the current session's active org (spec 021 plan 2). Validated + * against membership (spec AC #8 — a user can only act in orgs they belong + * to); superadmin cross-org access continues via the `x-org-context` header + * (spec AC #7) and is unaffected by this column. + */ + setActiveOrg: protectedProcedure + .input(z.object({ orgId: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + const membership = await getOrgMembership(ctx.user.id, input.orgId); + if (!membership) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'You are not a member of this organization', + }); + } + if (!ctx.token) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } + await setSessionActiveOrg(ctx.token, input.orgId); + return { activeOrgId: input.orgId, role: membership.role }; + }), }); diff --git a/src/api/routers/users.ts b/src/api/routers/users.ts index 01d7c8a66..250cec60a 100644 --- a/src/api/routers/users.ts +++ b/src/api/routers/users.ts @@ -9,16 +9,48 @@ import { listOrgUsers, updateUser, } from '../../db/repositories/usersRepository.js'; +import { resolveActorRoleInOrg } from '../context.js'; import { adminProcedure, router } from '../trpc.js'; type Role = 'member' | 'admin' | 'superadmin'; type ActorContext = { id: string; role: Role; effectiveOrgId: string }; type TargetUser = { id: string; orgId: string; role: string }; +/** + * Resolve the caller's role *in the effective org* (spec 021 plan 2). + * + * The `adminProcedure` middleware is a coarse global-role gate; this refines it + * with the per-org membership role so an org admin who has switched into an org + * where they are only a member cannot perform admin actions there (spec AC #8). + * Superadmin stays global (spec AC #7). + */ +function resolveActorRole(ctx: { + user: { id: string; role: Role; orgId: string }; + effectiveOrgId: string; +}): Promise { + return resolveActorRoleInOrg({ + userId: ctx.user.id, + globalRole: ctx.user.role, + homeOrgId: ctx.user.orgId, + orgId: ctx.effectiveOrgId, + }); +} + +/** Require the caller to be an admin (or superadmin) in the effective org. */ +function assertOrgAdmin(actorRole: Role): void { + if (actorRole !== 'admin' && actorRole !== 'superadmin') { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Admin access required' }); + } +} + /** * Centralizes the permission rules for editing/deleting a user. Throws TRPCError * with the appropriate code on any policy violation; returns silently if allowed. * + * `actor.role` is the caller's PER-ORG role (resolved via `resolveActorRole`), + * not the global `users.role` — per-org membership governs permissions + * (spec 021 AC #4). + * * Rules (apply to both `update` and `delete`): * 1. Cross-org access is hidden as `NOT_FOUND` (don't leak existence) unless * the actor is a superadmin (who can act across orgs). @@ -61,7 +93,9 @@ function assertUserAccessAllowed( export const usersRouter = router({ list: adminProcedure.query(async ({ ctx }) => { - if (ctx.user.role === 'superadmin') { + const actorRole = await resolveActorRole(ctx); + assertOrgAdmin(actorRole); + if (actorRole === 'superadmin') { return listOrgUsers(ctx.effectiveOrgId); } return listOrgUsers(ctx.effectiveOrgId, { excludeRole: 'superadmin' }); @@ -77,10 +111,13 @@ export const usersRouter = router({ }), ) .mutation(async ({ ctx, input }) => { + const actorRole = await resolveActorRole(ctx); + assertOrgAdmin(actorRole); + const role = input.role ?? 'member'; // Only superadmins can create users with superadmin role - if (role === 'superadmin' && ctx.user.role !== 'superadmin') { + if (role === 'superadmin' && actorRole !== 'superadmin') { throw new TRPCError({ code: 'FORBIDDEN', message: 'Only superadmins can create superadmin users', @@ -109,6 +146,9 @@ export const usersRouter = router({ }), ) .mutation(async ({ ctx, input }) => { + const actorRole = await resolveActorRole(ctx); + assertOrgAdmin(actorRole); + const targetUser = await getUserById(input.id); if (!targetUser) { throw new TRPCError({ code: 'NOT_FOUND' }); @@ -117,7 +157,7 @@ export const usersRouter = router({ assertUserAccessAllowed( { id: ctx.user.id, - role: ctx.user.role as Role, + role: actorRole, effectiveOrgId: ctx.effectiveOrgId, }, targetUser, @@ -148,6 +188,9 @@ export const usersRouter = router({ }), delete: adminProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => { + const actorRole = await resolveActorRole(ctx); + assertOrgAdmin(actorRole); + // Prevent self-deletion if (ctx.user.id === input.id) { throw new TRPCError({ @@ -162,7 +205,7 @@ export const usersRouter = router({ } assertUserAccessAllowed( - { id: ctx.user.id, role: ctx.user.role as Role, effectiveOrgId: ctx.effectiveOrgId }, + { id: ctx.user.id, role: actorRole, effectiveOrgId: ctx.effectiveOrgId }, targetUser, { verb: 'delete' }, ); diff --git a/src/dashboard.ts b/src/dashboard.ts index 148dec2c7..2faa6c8cb 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -78,8 +78,13 @@ app.use( router: appRouter, createContext: async (_opts, c) => { const token = getCookie(c, SESSION_COOKIE_NAME) || null; - const user = token ? await resolveUserFromSession(token) : null; - const effectiveOrgId = await computeEffectiveOrgId(user, c.req.header('x-org-context')); + const resolved = token ? await resolveUserFromSession(token) : null; + const user = resolved?.user ?? null; + const effectiveOrgId = await computeEffectiveOrgId( + user, + c.req.header('x-org-context'), + resolved?.activeOrgId ?? null, + ); return { user, effectiveOrgId, token }; }, }), diff --git a/src/db/repositories/orgMembershipsRepository.ts b/src/db/repositories/orgMembershipsRepository.ts new file mode 100644 index 000000000..1f863e574 --- /dev/null +++ b/src/db/repositories/orgMembershipsRepository.ts @@ -0,0 +1,59 @@ +import { and, eq } from 'drizzle-orm'; +import { getDb } from '../client.js'; +import { organizations, orgMemberships } from '../schema/index.js'; + +/** + * Read helpers for the multi-org membership model (spec 021, plan 2 of 4). + * + * Plan 1 shipped the `org_memberships` table dormant; plan 2 is the first + * consumer. These reads drive effective-org resolution, the active-org switch + * endpoint, and the per-org actor-role helper that user-management permission + * checks consume. Grant/create/list mutations land in plan 3. + */ + +/** A user's membership in a single org. */ +export interface OrgMembership { + orgId: string; + /** Per-org role ('member' | 'admin'). Distinct from the global `users.role`. */ + role: string; +} + +/** An org a user belongs to, with its name and the user's per-org role. */ +export interface MyOrg { + id: string; + name: string; + role: string; +} + +/** + * Get a user's membership in a specific org, or `null` when they are not a + * member. Used to validate active-org switches and to resolve the per-org role. + */ +export async function getOrgMembership( + userId: string, + orgId: string, +): Promise { + const db = getDb(); + const [row] = await db + .select({ orgId: orgMemberships.orgId, role: orgMemberships.role }) + .from(orgMemberships) + .where(and(eq(orgMemberships.userId, userId), eq(orgMemberships.orgId, orgId))); + return row ?? null; +} + +/** + * List every org a user is a member of, joined with the org name and the + * user's per-org role. Powers the `listMyOrgs` switcher read. + */ +export async function listOrgMembershipsForUser(userId: string): Promise { + const db = getDb(); + return db + .select({ + id: orgMemberships.orgId, + name: organizations.name, + role: orgMemberships.role, + }) + .from(orgMemberships) + .innerJoin(organizations, eq(orgMemberships.orgId, organizations.id)) + .where(eq(orgMemberships.userId, userId)); +} diff --git a/src/db/repositories/usersRepository.ts b/src/db/repositories/usersRepository.ts index f927ae8f7..d7e02e43d 100644 --- a/src/db/repositories/usersRepository.ts +++ b/src/db/repositories/usersRepository.ts @@ -68,12 +68,31 @@ export async function getSessionByToken(token: string) { sessionId: sessions.id, userId: sessions.userId, expiresAt: sessions.expiresAt, + // Multi-org membership (spec 021 plan 2): the org this session is + // currently acting in. NULL falls back to the user's home org so an + // existing session is never logged out. + activeOrgId: sessions.activeOrgId, }) .from(sessions) .where(and(eq(sessions.token, token), gt(sessions.expiresAt, now))); return row ?? null; } +/** + * Set (or clear) the active org on a session, identified by its token. + * Pass `null` to fall back to the user's home org on the next request. + * + * Callers must validate the target org against the user's membership before + * calling this (spec 021 plan 2 — `auth.setActiveOrg`). + */ +export async function setSessionActiveOrg( + token: string, + activeOrgId: string | null, +): Promise { + const db = getDb(); + await db.update(sessions).set({ activeOrgId }).where(eq(sessions.token, token)); +} + export async function deleteSession(token: string): Promise { const db = getDb(); await db.delete(sessions).where(eq(sessions.token, token)); diff --git a/tests/integration/db/orgMembershipsAccess.test.ts b/tests/integration/db/orgMembershipsAccess.test.ts new file mode 100644 index 000000000..ed3d4a15c --- /dev/null +++ b/tests/integration/db/orgMembershipsAccess.test.ts @@ -0,0 +1,151 @@ +/** + * Integration tests for multi-org membership access resolution + * (spec 021, plan 2 of 4). + * + * Exercises the plan-2 primitives against a real database: + * - orgMembershipsRepository reads (getOrgMembership, listOrgMembershipsForUser) + * - session active-org round-trip (setSessionActiveOrg → getSessionByToken) + * - membership-aware effective-org resolution (computeEffectiveOrgId) + * - per-org actor-role evaluation (resolveActorRoleInOrg) + * + * The no-logout guarantee is asserted directly: a session with a NULL or + * stale active org resolves to the user's home org instead of failing. + */ +import { beforeEach, describe, expect, it } from 'vitest'; +import { computeEffectiveOrgId, resolveActorRoleInOrg } from '../../../src/api/context.js'; +import { + getOrgMembership, + listOrgMembershipsForUser, +} from '../../../src/db/repositories/orgMembershipsRepository.js'; +import { + getSessionByToken, + setSessionActiveOrg, +} from '../../../src/db/repositories/usersRepository.js'; +import { truncateAll } from '../helpers/db.js'; +import { seedMembership, seedOrg, seedSession, seedUser } from '../helpers/seed.js'; + +type TestUser = { + id: string; + orgId: string; + email: string; + name: string; + role: 'member' | 'admin' | 'superadmin'; +}; + +function asTRPCUser(row: { id: string; orgId: string; role: string }): TestUser { + return { + id: row.id, + orgId: row.orgId, + email: 'user@example.com', + name: 'User', + role: row.role as TestUser['role'], + }; +} + +describe('multi-org membership access (integration)', () => { + beforeEach(async () => { + await truncateAll(); + await seedOrg('home-org', 'Home Org'); + await seedOrg('other-org', 'Other Org'); + }); + + describe('orgMembershipsRepository', () => { + it('getOrgMembership returns the per-org role when a membership exists', async () => { + const user = await seedUser({ orgId: 'home-org', email: 'm@example.com', role: 'member' }); + await seedMembership({ userId: user.id, orgId: 'other-org', role: 'admin' }); + + const membership = await getOrgMembership(user.id, 'other-org'); + expect(membership).toEqual({ orgId: 'other-org', role: 'admin' }); + }); + + it('getOrgMembership returns null when there is no membership', async () => { + const user = await seedUser({ orgId: 'home-org', email: 'n@example.com' }); + expect(await getOrgMembership(user.id, 'other-org')).toBeNull(); + }); + + it('listOrgMembershipsForUser returns each org with its name and per-org role', async () => { + const user = await seedUser({ orgId: 'home-org', email: 'l@example.com', role: 'admin' }); + await seedMembership({ userId: user.id, orgId: 'home-org', role: 'admin' }); + await seedMembership({ userId: user.id, orgId: 'other-org', role: 'member' }); + + const orgs = await listOrgMembershipsForUser(user.id); + expect(orgs).toEqual( + expect.arrayContaining([ + { id: 'home-org', name: 'Home Org', role: 'admin' }, + { id: 'other-org', name: 'Other Org', role: 'member' }, + ]), + ); + expect(orgs).toHaveLength(2); + }); + }); + + describe('session active-org round-trip', () => { + it('setSessionActiveOrg persists and getSessionByToken reads it back', async () => { + const user = await seedUser({ orgId: 'home-org', email: 's@example.com' }); + await seedSession({ userId: user.id, token: 'tok-active' }); + + await setSessionActiveOrg('tok-active', 'other-org'); + + const session = await getSessionByToken('tok-active'); + expect(session?.activeOrgId).toBe('other-org'); + }); + + it('a fresh session reports a NULL active org (no-logout default)', async () => { + const user = await seedUser({ orgId: 'home-org', email: 'f@example.com' }); + await seedSession({ userId: user.id, token: 'tok-null' }); + + const session = await getSessionByToken('tok-null'); + expect(session?.activeOrgId).toBeNull(); + }); + }); + + describe('computeEffectiveOrgId', () => { + it('returns the active org when the member has a membership there', async () => { + const row = await seedUser({ orgId: 'home-org', email: 'a@example.com', role: 'member' }); + await seedMembership({ userId: row.id, orgId: 'other-org', role: 'member' }); + + const eff = await computeEffectiveOrgId(asTRPCUser(row), undefined, 'other-org'); + expect(eff).toBe('other-org'); + }); + + it('falls back to the home org when the active-org membership is gone (no-logout)', async () => { + const row = await seedUser({ orgId: 'home-org', email: 'b@example.com', role: 'member' }); + // No membership seeded in other-org. + const eff = await computeEffectiveOrgId(asTRPCUser(row), undefined, 'other-org'); + expect(eff).toBe('home-org'); + }); + + it('falls back to the home org when the session has no active org (no-logout)', async () => { + const row = await seedUser({ orgId: 'home-org', email: 'c@example.com', role: 'member' }); + const eff = await computeEffectiveOrgId(asTRPCUser(row), undefined, null); + expect(eff).toBe('home-org'); + }); + }); + + describe('resolveActorRoleInOrg', () => { + it('uses the per-org membership role for a switched org', async () => { + const row = await seedUser({ orgId: 'home-org', email: 'd@example.com', role: 'admin' }); + await seedMembership({ userId: row.id, orgId: 'other-org', role: 'member' }); + + // Admin at home, but only a member in other-org (spec AC #8). + const role = await resolveActorRoleInOrg({ + userId: row.id, + globalRole: 'admin', + homeOrgId: 'home-org', + orgId: 'other-org', + }); + expect(role).toBe('member'); + }); + + it('superadmin stays superadmin in any org without a membership (AC #7)', async () => { + const row = await seedUser({ orgId: 'home-org', email: 'e@example.com', role: 'admin' }); + const role = await resolveActorRoleInOrg({ + userId: row.id, + globalRole: 'superadmin', + homeOrgId: 'home-org', + orgId: 'other-org', + }); + expect(role).toBe('superadmin'); + }); + }); +}); diff --git a/tests/integration/helpers/seed.ts b/tests/integration/helpers/seed.ts index 47101e6b7..aaaaa5d90 100644 --- a/tests/integration/helpers/seed.ts +++ b/tests/integration/helpers/seed.ts @@ -7,6 +7,7 @@ import { agentRuns, agentTriggerConfigs, organizations, + orgMemberships, projectIntegrations, projects, promptPartials, @@ -273,7 +274,12 @@ export async function seedPromptPartial( /** * Seeds a session for a user. */ -export async function seedSession(overrides: { userId: string; token?: string; expiresAt?: Date }) { +export async function seedSession(overrides: { + userId: string; + token?: string; + expiresAt?: Date; + activeOrgId?: string | null; +}) { const db = getDb(); const futureDate = new Date(); futureDate.setDate(futureDate.getDate() + 30); @@ -283,6 +289,24 @@ export async function seedSession(overrides: { userId: string; token?: string; e userId: overrides.userId, token: overrides.token ?? 'test-session-token', expiresAt: overrides.expiresAt ?? futureDate, + activeOrgId: overrides.activeOrgId ?? null, + }) + .returning(); + return row; +} + +/** + * Seeds an org membership row (spec 021): links a user to an org with a + * per-org role. + */ +export async function seedMembership(overrides: { userId: string; orgId?: string; role?: string }) { + const db = getDb(); + const [row] = await db + .insert(orgMemberships) + .values({ + userId: overrides.userId, + orgId: overrides.orgId ?? 'test-org', + role: overrides.role ?? 'member', }) .returning(); return row; diff --git a/tests/unit/api/access-control.test.ts b/tests/unit/api/access-control.test.ts index 15dc48320..3d69f792d 100644 --- a/tests/unit/api/access-control.test.ts +++ b/tests/unit/api/access-control.test.ts @@ -53,6 +53,14 @@ vi.mock('../../../src/db/repositories/credentialsRepository.js', () => ({ deleteProjectCredential: vi.fn(), })); +// Multi-org membership (spec 021 plan 2): membership-aware effective-org +// resolution + per-org actor-role helper read through this repository. +const mockGetOrgMembership = vi.fn(); + +vi.mock('../../../src/db/repositories/orgMembershipsRepository.js', () => ({ + getOrgMembership: (...args: unknown[]) => mockGetOrgMembership(...args), +})); + const mockDbSelect = vi.fn(); const mockDbFrom = vi.fn(); const mockDbWhere = vi.fn(); @@ -92,7 +100,7 @@ vi.mock('../../../src/utils/logging.js', () => ({ // Imports (after mocks) // ========================================================================== -import { computeEffectiveOrgId } from '../../../src/api/context.js'; +import { computeEffectiveOrgId, resolveActorRoleInOrg } from '../../../src/api/context.js'; import { authRouter } from '../../../src/api/routers/auth.js'; import { organizationRouter } from '../../../src/api/routers/organization.js'; import { projectsRouter } from '../../../src/api/routers/projects.js'; @@ -183,6 +191,126 @@ describe('computeEffectiveOrgId', () => { }); }); +// ========================================================================== +// Section 1b: computeEffectiveOrgId — membership-aware active-org resolution +// (spec 021 plan 2) +// ========================================================================== + +describe('computeEffectiveOrgId — active org + membership', () => { + it('returns the active org when the member has a membership there', async () => { + mockGetOrgMembership.mockResolvedValue({ orgId: 'org-2', role: 'member' }); + const result = await computeEffectiveOrgId(memberUser, undefined, 'org-2'); + expect(result).toBe('org-2'); + expect(mockGetOrgMembership).toHaveBeenCalledWith('user-2', 'org-2'); + }); + + it('falls back to the home org when the active-org membership is gone (no-logout)', async () => { + mockGetOrgMembership.mockResolvedValue(null); + const result = await computeEffectiveOrgId(memberUser, undefined, 'org-2'); + expect(result).toBe('org-1'); + expect(mockGetOrgMembership).toHaveBeenCalledWith('user-2', 'org-2'); + }); + + it('falls back to the home org when there is no active org (no-logout default)', async () => { + const result = await computeEffectiveOrgId(memberUser, undefined, null); + expect(result).toBe('org-1'); + expect(mockGetOrgMembership).not.toHaveBeenCalled(); + }); + + it('skips the membership lookup when the active org equals the home org', async () => { + const result = await computeEffectiveOrgId(memberUser, undefined, 'org-1'); + expect(result).toBe('org-1'); + expect(mockGetOrgMembership).not.toHaveBeenCalled(); + }); + + it('admin honours their active org when a membership exists there', async () => { + mockGetOrgMembership.mockResolvedValue({ orgId: 'org-2', role: 'admin' }); + const result = await computeEffectiveOrgId(adminUser, undefined, 'org-2'); + expect(result).toBe('org-2'); + }); + + it('superadmin ignores active org and stays on the header path (unchanged)', async () => { + const superAdmin = createMockUser({ role: 'superadmin' }); + // No header, but an active org is set — superadmin resolution must not + // consult membership or active_org_id; it returns the home org. + const result = await computeEffectiveOrgId(superAdmin, undefined, 'org-2'); + expect(result).toBe('org-1'); + expect(mockGetOrgMembership).not.toHaveBeenCalled(); + }); +}); + +// ========================================================================== +// Section 1c: resolveActorRoleInOrg — per-org role evaluation (spec 021 plan 2) +// ========================================================================== + +describe('resolveActorRoleInOrg', () => { + it('returns superadmin without a membership lookup (global role)', async () => { + const role = await resolveActorRoleInOrg({ + userId: 'user-1', + globalRole: 'superadmin', + homeOrgId: 'org-1', + orgId: 'org-2', + }); + expect(role).toBe('superadmin'); + expect(mockGetOrgMembership).not.toHaveBeenCalled(); + }); + + it('returns the per-org membership role (admin) for the active org', async () => { + mockGetOrgMembership.mockResolvedValue({ orgId: 'org-2', role: 'admin' }); + const role = await resolveActorRoleInOrg({ + userId: 'user-2', + globalRole: 'member', + homeOrgId: 'org-1', + orgId: 'org-2', + }); + expect(role).toBe('admin'); + }); + + it('downgrades an org admin to member in an org where they are only a member (AC #8)', async () => { + mockGetOrgMembership.mockResolvedValue({ orgId: 'org-2', role: 'member' }); + const role = await resolveActorRoleInOrg({ + userId: 'user-1', + globalRole: 'admin', + homeOrgId: 'org-1', + orgId: 'org-2', + }); + expect(role).toBe('member'); + }); + + it('falls back to the global role in the home org when no membership row exists', async () => { + mockGetOrgMembership.mockResolvedValue(null); + const role = await resolveActorRoleInOrg({ + userId: 'user-1', + globalRole: 'admin', + homeOrgId: 'org-1', + orgId: 'org-1', + }); + expect(role).toBe('admin'); + }); + + it('grants least privilege in a non-home org with no membership row', async () => { + mockGetOrgMembership.mockResolvedValue(null); + const role = await resolveActorRoleInOrg({ + userId: 'user-1', + globalRole: 'admin', + homeOrgId: 'org-1', + orgId: 'org-2', + }); + expect(role).toBe('member'); + }); + + it('normalizes an unexpected membership role to least privilege', async () => { + mockGetOrgMembership.mockResolvedValue({ orgId: 'org-2', role: 'weird' }); + const role = await resolveActorRoleInOrg({ + userId: 'user-2', + globalRole: 'admin', + homeOrgId: 'org-1', + orgId: 'org-2', + }); + expect(role).toBe('member'); + }); +}); + // ========================================================================== // Section 2: Middleware edge cases // ========================================================================== diff --git a/tests/unit/api/auth/session.test.ts b/tests/unit/api/auth/session.test.ts index 1ba066b81..dc82caa2c 100644 --- a/tests/unit/api/auth/session.test.ts +++ b/tests/unit/api/auth/session.test.ts @@ -11,18 +11,20 @@ vi.mock('../../../../src/db/repositories/usersRepository.js', () => ({ import { resolveUserFromSession } from '../../../../src/api/auth/session.js'; describe('resolveUserFromSession', () => { - it('returns DashboardUser when token maps to valid session and user', async () => { - const mockUser = { - id: 'user-1', - orgId: 'org-1', - email: 'test@example.com', - name: 'Test User', - role: 'admin', - }; + const mockUser = { + id: 'user-1', + orgId: 'org-1', + email: 'test@example.com', + name: 'Test User', + role: 'admin', + }; + + it('returns the user and active org when token maps to a valid session', async () => { mockGetSessionByToken.mockResolvedValue({ sessionId: 'session-1', userId: 'user-1', expiresAt: new Date('2099-01-01'), + activeOrgId: 'org-2', }); mockGetUserById.mockResolvedValue(mockUser); @@ -30,7 +32,21 @@ describe('resolveUserFromSession', () => { expect(mockGetSessionByToken).toHaveBeenCalledWith('valid-token'); expect(mockGetUserById).toHaveBeenCalledWith('user-1'); - expect(result).toEqual(mockUser); + expect(result).toEqual({ user: mockUser, activeOrgId: 'org-2' }); + }); + + it('returns activeOrgId null when the session has no active org (no-logout default)', async () => { + mockGetSessionByToken.mockResolvedValue({ + sessionId: 'session-1', + userId: 'user-1', + expiresAt: new Date('2099-01-01'), + activeOrgId: null, + }); + mockGetUserById.mockResolvedValue(mockUser); + + const result = await resolveUserFromSession('valid-token'); + + expect(result).toEqual({ user: mockUser, activeOrgId: null }); }); it('returns null when session not found', async () => { @@ -47,6 +63,7 @@ describe('resolveUserFromSession', () => { sessionId: 'session-1', userId: 'deleted-user', expiresAt: new Date('2099-01-01'), + activeOrgId: null, }); mockGetUserById.mockResolvedValue(null); diff --git a/tests/unit/api/routers/auth.test.ts b/tests/unit/api/routers/auth.test.ts index 6ed4d9ed7..a458c3749 100644 --- a/tests/unit/api/routers/auth.test.ts +++ b/tests/unit/api/routers/auth.test.ts @@ -2,13 +2,23 @@ import { describe, expect, it, vi } from 'vitest'; import { createMockSuperAdmin, createMockUser } from '../../../helpers/factories.js'; import { createCallerFor, expectTRPCError } from '../../../helpers/trpcTestHarness.js'; -const { mockListAllOrganizations, mockGetOrganization, mockUpdateUser, mockDeleteUserSessions } = - vi.hoisted(() => ({ - mockListAllOrganizations: vi.fn(), - mockGetOrganization: vi.fn(), - mockUpdateUser: vi.fn(), - mockDeleteUserSessions: vi.fn(), - })); +const { + mockListAllOrganizations, + mockGetOrganization, + mockUpdateUser, + mockDeleteUserSessions, + mockSetSessionActiveOrg, + mockGetOrgMembership, + mockListOrgMembershipsForUser, +} = vi.hoisted(() => ({ + mockListAllOrganizations: vi.fn(), + mockGetOrganization: vi.fn(), + mockUpdateUser: vi.fn(), + mockDeleteUserSessions: vi.fn(), + mockSetSessionActiveOrg: vi.fn(), + mockGetOrgMembership: vi.fn(), + mockListOrgMembershipsForUser: vi.fn(), +})); vi.mock('../../../../src/db/repositories/settingsRepository.js', () => ({ listAllOrganizations: mockListAllOrganizations, @@ -18,6 +28,12 @@ vi.mock('../../../../src/db/repositories/settingsRepository.js', () => ({ vi.mock('../../../../src/db/repositories/usersRepository.js', () => ({ updateUser: mockUpdateUser, deleteUserSessions: mockDeleteUserSessions, + setSessionActiveOrg: mockSetSessionActiveOrg, +})); + +vi.mock('../../../../src/db/repositories/orgMembershipsRepository.js', () => ({ + getOrgMembership: mockGetOrgMembership, + listOrgMembershipsForUser: mockListOrgMembershipsForUser, })); import { authRouter } from '../../../../src/api/routers/auth.js'; @@ -100,4 +116,82 @@ describe('authRouter', () => { ); }); }); + + // ===================================================================== + // Multi-org membership switcher primitives (spec 021 plan 2) + // ===================================================================== + describe('listMyOrgs', () => { + it("returns the current user's memberships with per-org roles", async () => { + const memberships = [ + { id: 'org-1', name: 'Org One', role: 'admin' }, + { id: 'org-2', name: 'Org Two', role: 'member' }, + ]; + mockListOrgMembershipsForUser.mockResolvedValue(memberships); + const mockUser = createMockUser(); + const caller = createCaller({ + user: mockUser, + effectiveOrgId: mockUser.orgId, + token: 'tok', + }); + + const result = await caller.listMyOrgs(); + + expect(mockListOrgMembershipsForUser).toHaveBeenCalledWith(mockUser.id); + expect(result).toEqual(memberships); + }); + + it('throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null, effectiveOrgId: null, token: null }); + await expectTRPCError(caller.listMyOrgs(), 'UNAUTHORIZED'); + }); + }); + + describe('setActiveOrg', () => { + it('switches the session active org when the user is a member of the target', async () => { + mockGetOrgMembership.mockResolvedValue({ orgId: 'org-2', role: 'member' }); + const mockUser = createMockUser(); + const caller = createCaller({ + user: mockUser, + effectiveOrgId: mockUser.orgId, + token: 'session-token', + }); + + const result = await caller.setActiveOrg({ orgId: 'org-2' }); + + expect(mockGetOrgMembership).toHaveBeenCalledWith(mockUser.id, 'org-2'); + expect(mockSetSessionActiveOrg).toHaveBeenCalledWith('session-token', 'org-2'); + expect(result).toEqual({ activeOrgId: 'org-2', role: 'member' }); + }); + + it('rejects switching to an org the user is not a member of (FORBIDDEN)', async () => { + mockGetOrgMembership.mockResolvedValue(null); + const mockUser = createMockUser(); + const caller = createCaller({ + user: mockUser, + effectiveOrgId: mockUser.orgId, + token: 'session-token', + }); + + await expectTRPCError(caller.setActiveOrg({ orgId: 'org-2' }), 'FORBIDDEN'); + expect(mockSetSessionActiveOrg).not.toHaveBeenCalled(); + }); + + it('throws UNAUTHORIZED when the session has no token', async () => { + mockGetOrgMembership.mockResolvedValue({ orgId: 'org-2', role: 'member' }); + const mockUser = createMockUser(); + const caller = createCaller({ + user: mockUser, + effectiveOrgId: mockUser.orgId, + token: null, + }); + + await expectTRPCError(caller.setActiveOrg({ orgId: 'org-2' }), 'UNAUTHORIZED'); + expect(mockSetSessionActiveOrg).not.toHaveBeenCalled(); + }); + + it('throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null, effectiveOrgId: null, token: null }); + await expectTRPCError(caller.setActiveOrg({ orgId: 'org-2' }), 'UNAUTHORIZED'); + }); + }); }); diff --git a/tests/unit/api/routers/users.test.ts b/tests/unit/api/routers/users.test.ts index 08c2bd0ad..7bd1dfb4f 100644 --- a/tests/unit/api/routers/users.test.ts +++ b/tests/unit/api/routers/users.test.ts @@ -10,6 +10,7 @@ const { mockGetUserById, mockDeleteUserSessions, mockBcryptHash, + mockGetOrgMembership, } = vi.hoisted(() => ({ mockListOrgUsers: vi.fn(), mockCreateUser: vi.fn(), @@ -18,6 +19,7 @@ const { mockGetUserById: vi.fn(), mockDeleteUserSessions: vi.fn(), mockBcryptHash: vi.fn(), + mockGetOrgMembership: vi.fn(), })); vi.mock('../../../../src/db/repositories/usersRepository.js', () => ({ @@ -29,6 +31,14 @@ vi.mock('../../../../src/db/repositories/usersRepository.js', () => ({ deleteUserSessions: mockDeleteUserSessions, })); +// Per-org actor-role helper (spec 021 plan 2) reads memberships through this +// repository. Default: no membership row → resolveActorRoleInOrg falls back to +// the global role for the home org, so the existing admin/member/superadmin +// tests (which act in their home org) are unaffected. +vi.mock('../../../../src/db/repositories/orgMembershipsRepository.js', () => ({ + getOrgMembership: mockGetOrgMembership, +})); + vi.mock('bcrypt', () => ({ default: { hash: mockBcryptHash, @@ -47,6 +57,9 @@ describe('usersRouter', () => { beforeEach(() => { mockBcryptHash.mockResolvedValue('hashed-password'); mockDeleteUserSessions.mockResolvedValue(undefined); + // Default: caller has no explicit membership row, so the per-org role + // resolver falls back to the global role for their home org. + mockGetOrgMembership.mockResolvedValue(null); }); describe('list', () => { @@ -518,4 +531,76 @@ describe('usersRouter', () => { }); }); }); + + // ===================================================================== + // Per-org role governs user management (spec 021 plan 2) + // AC #4: per-org role governs permissions + // AC #7: superadmin cross-org unchanged + // AC #8: an org admin can't act cross-org + // ===================================================================== + describe('per-org role (spec 021 plan 2)', () => { + it('list: an org admin switched to an org where they are only a member is denied (AC #8)', async () => { + mockGetOrgMembership.mockResolvedValue({ orgId: 'org-2', role: 'member' }); + const caller = createCaller({ user: mockAdminUser, effectiveOrgId: 'org-2' }); + + await expect(caller.list()).rejects.toMatchObject({ code: 'FORBIDDEN' }); + expect(mockListOrgUsers).not.toHaveBeenCalled(); + expect(mockGetOrgMembership).toHaveBeenCalledWith('user-1', 'org-2'); + }); + + it('list: an admin in the switched org lists that org with the per-org admin role (AC #4)', async () => { + mockGetOrgMembership.mockResolvedValue({ orgId: 'org-2', role: 'admin' }); + mockListOrgUsers.mockResolvedValue([]); + const caller = createCaller({ user: mockAdminUser, effectiveOrgId: 'org-2' }); + + await caller.list(); + + expect(mockListOrgUsers).toHaveBeenCalledWith('org-2', { excludeRole: 'superadmin' }); + }); + + it('create: an org admin switched to a member-org cannot create users there (AC #8)', async () => { + mockGetOrgMembership.mockResolvedValue({ orgId: 'org-2', role: 'member' }); + const caller = createCaller({ user: mockAdminUser, effectiveOrgId: 'org-2' }); + + await expect( + caller.create({ email: 'x@example.com', name: 'X', password: 'secret123456789' }), + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + expect(mockCreateUser).not.toHaveBeenCalled(); + }); + + it('update: an org admin switched to a member-org cannot edit users there (AC #8)', async () => { + mockGetOrgMembership.mockResolvedValue({ orgId: 'org-2', role: 'member' }); + const caller = createCaller({ user: mockAdminUser, effectiveOrgId: 'org-2' }); + + await expect(caller.update({ id: 'user-2', name: 'X' })).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + // Denied before the target is even loaded. + expect(mockGetUserById).not.toHaveBeenCalled(); + expect(mockUpdateUser).not.toHaveBeenCalled(); + }); + + it('delete: an org admin switched to a member-org cannot delete users there (AC #8)', async () => { + mockGetOrgMembership.mockResolvedValue({ orgId: 'org-2', role: 'member' }); + const caller = createCaller({ user: mockAdminUser, effectiveOrgId: 'org-2' }); + + await expect(caller.delete({ id: 'user-2' })).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + expect(mockDeleteUser).not.toHaveBeenCalled(); + }); + + it('superadmin acts in any org without a membership row (AC #7)', async () => { + // No membership in org-2; the global superadmin role short-circuits the + // per-org resolver, so getOrgMembership is never consulted. + mockGetUserById.mockResolvedValue({ id: 'user-2', orgId: 'org-2', role: 'member' }); + mockUpdateUser.mockResolvedValue(undefined); + const caller = createCaller({ user: mockSuperAdmin, effectiveOrgId: 'org-2' }); + + await caller.update({ id: 'user-2', name: 'Renamed' }); + + expect(mockUpdateUser).toHaveBeenCalledWith('user-2', { name: 'Renamed' }); + expect(mockGetOrgMembership).not.toHaveBeenCalled(); + }); + }); }); diff --git a/tests/unit/db/repositories/orgMembershipsRepository.test.ts b/tests/unit/db/repositories/orgMembershipsRepository.test.ts new file mode 100644 index 000000000..9c6369a1c --- /dev/null +++ b/tests/unit/db/repositories/orgMembershipsRepository.test.ts @@ -0,0 +1,68 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createMockDbWithGetDb } from '../../../helpers/mockDb.js'; +import { mockDbClientModule } from '../../../helpers/sharedMocks.js'; + +vi.mock('../../../../src/db/client.js', () => mockDbClientModule); + +vi.mock('../../../../src/db/schema/index.js', () => ({ + orgMemberships: { + id: 'id', + userId: 'user_id', + orgId: 'org_id', + role: 'role', + }, + organizations: { + id: 'id', + name: 'name', + }, +})); + +import { + getOrgMembership, + listOrgMembershipsForUser, +} from '../../../../src/db/repositories/orgMembershipsRepository.js'; + +describe('orgMembershipsRepository', () => { + let mockDb: ReturnType; + + beforeEach(() => { + mockDb = createMockDbWithGetDb(); + }); + + describe('getOrgMembership', () => { + it('returns the membership row when the user belongs to the org', async () => { + mockDb.chain.where.mockResolvedValueOnce([{ orgId: 'org-2', role: 'admin' }]); + + const result = await getOrgMembership('user-1', 'org-2'); + expect(result).toEqual({ orgId: 'org-2', role: 'admin' }); + }); + + it('returns null when there is no membership', async () => { + mockDb.chain.where.mockResolvedValueOnce([]); + + const result = await getOrgMembership('user-1', 'org-2'); + expect(result).toBeNull(); + }); + }); + + describe('listOrgMembershipsForUser', () => { + it('returns every org the user belongs to joined with the org name', async () => { + const rows = [ + { id: 'org-1', name: 'Org One', role: 'admin' }, + { id: 'org-2', name: 'Org Two', role: 'member' }, + ]; + mockDb.chain.where.mockResolvedValueOnce(rows); + + const result = await listOrgMembershipsForUser('user-1'); + expect(result).toEqual(rows); + expect(mockDb.chain.innerJoin).toHaveBeenCalled(); + }); + + it('returns an empty array when the user has no memberships', async () => { + mockDb.chain.where.mockResolvedValueOnce([]); + + const result = await listOrgMembershipsForUser('user-1'); + expect(result).toEqual([]); + }); + }); +}); diff --git a/tests/unit/db/repositories/usersRepository.test.ts b/tests/unit/db/repositories/usersRepository.test.ts index ccb1b5468..ee3c74360 100644 --- a/tests/unit/db/repositories/usersRepository.test.ts +++ b/tests/unit/db/repositories/usersRepository.test.ts @@ -20,6 +20,7 @@ vi.mock('../../../../src/db/schema/index.js', () => ({ userId: 'user_id', token: 'token', expiresAt: 'expires_at', + activeOrgId: 'active_org_id', }, })); @@ -34,6 +35,7 @@ import { getUserByEmail, getUserById, listOrgUsers, + setSessionActiveOrg, updateUser, } from '../../../../src/db/repositories/usersRepository.js'; @@ -128,6 +130,25 @@ describe('usersRepository', () => { }); }); + describe('setSessionActiveOrg', () => { + it('updates the active org on the session by token', async () => { + mockDb.chain.where.mockResolvedValueOnce(undefined); + + await setSessionActiveOrg('session-token', 'org-2'); + + expect(mockDb.db.update).toHaveBeenCalled(); + expect(mockDb.chain.set).toHaveBeenCalledWith({ activeOrgId: 'org-2' }); + }); + + it('clears the active org when passed null', async () => { + mockDb.chain.where.mockResolvedValueOnce(undefined); + + await setSessionActiveOrg('session-token', null); + + expect(mockDb.chain.set).toHaveBeenCalledWith({ activeOrgId: null }); + }); + }); + describe('deleteSession', () => { it('deletes session by token', async () => { mockDb.chain.where.mockResolvedValueOnce(undefined); From 58718b56d816945ebb2ae1fe26d10e73fac64f4c Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Wed, 24 Jun 2026 17:29:22 +0000 Subject: [PATCH 04/18] feat(users): grant membership, graceful create, member listing + CLI (spec 021 plan 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The additive admin side of multi-org membership (spec 021, plan 3 of 4): an admin of an org (or a superadmin) can grant an existing email a membership in their org with a per-org role, creating a user with an already-registered email returns a clear CONFLICT instead of a 500, and listing an org returns its true membership — including accounts whose home org is elsewhere. - addExistingUserToOrg grant mutation: org-admin/superadmin only (refined by the per-org actor role), NOT_FOUND when no account owns the email, idempotent re-grant (upsert updates the per-org role). - Graceful duplicate-email create: catch the Postgres unique violation (23505, unwrapped from drizzle's DrizzleQueryError cause) and map it to a typed CONFLICT whose message distinguishes "already a member here" from "account exists — add to this org". - createUserWithMembership: create mirrors a membership in the same transaction so new accounts appear in the membership-based listing; a duplicate email rolls back without an orphan membership. - listOrgMembers: membership-based listing joining org_memberships -> users with the per-org role; excludeGlobalRole keeps regular admins from seeing global superadmins. - CLI parity: new `cascade users add-to-org`, membership-based `list`, and clean conflict messaging on `create`. Satisfies spec ACs #1 (add existing account) and #2 (no more 500); #5 partial (membership listing API; UI render is plan 4). Co-Authored-By: Claude Opus 4.8 --- docs/architecture/09-database.md | 2 +- src/api/routers/users.ts | 144 ++++++++- src/cli/dashboard/users/add-to-org.ts | 48 +++ .../repositories/orgMembershipsRepository.ts | 87 +++++- src/db/repositories/usersRepository.ts | 45 ++- .../db/orgMembershipsManagement.test.ts | 162 +++++++++++ tests/unit/api/routers/users.test.ts | 273 +++++++++++++++--- tests/unit/cli/dashboard/users/users.test.ts | 64 ++++ .../orgMembershipsRepository.test.ts | 86 +++++- .../db/repositories/usersRepository.test.ts | 76 +++-- 10 files changed, 897 insertions(+), 90 deletions(-) create mode 100644 src/cli/dashboard/users/add-to-org.ts create mode 100644 tests/integration/db/orgMembershipsManagement.test.ts diff --git a/docs/architecture/09-database.md b/docs/architecture/09-database.md index 54ea61947..76a3bcee0 100644 --- a/docs/architecture/09-database.md +++ b/docs/architecture/09-database.md @@ -147,7 +147,7 @@ erDiagram | `pr_work_items` | Maps PRs and external alert sources to PM work items for run-link display and alert idempotency | Partial unique indexes on `(project_id, pr_number)`, `(project_id, work_item_id)`, and `(project_id, external_source, external_id)` when those values are present | | `webhook_logs` | Raw webhook payloads for debugging | — | | `users` | Dashboard users (email, bcrypt hash, role) | Org-scoped (`org_id` = home org, `role` = global role) | -| `org_memberships` | Multi-org membership: links a user to an org with a per-org role, so one account can belong to many orgs. `users.org_id`/`users.role` remain the home org + global role. Read by effective-org resolution + the per-org actor-role helper (spec 021 plan 2). | UNIQUE(`user_id`, `org_id`) | +| `org_memberships` | Multi-org membership: links a user to an org with a per-org role, so one account can belong to many orgs. `users.org_id`/`users.role` remain the home org + global role. Read by effective-org resolution + the per-org actor-role helper (spec 021 plan 2); written by the grant mutation (`users.addExistingUserToOrg`) and the membership-mirroring create, and read by membership-based member listing (spec 021 plan 3). | UNIQUE(`user_id`, `org_id`) | | `sessions` | Session tokens for cookie auth (30-day expiry); `active_org_id` (nullable) tracks the org the session is currently acting in for multi-org | — | | `debug_analyses` | AI debug analysis results | — | diff --git a/src/api/routers/users.ts b/src/api/routers/users.ts index 250cec60a..d20f2a4f7 100644 --- a/src/api/routers/users.ts +++ b/src/api/routers/users.ts @@ -2,11 +2,16 @@ import { TRPCError } from '@trpc/server'; import bcrypt from 'bcrypt'; import { z } from 'zod'; import { - createUser, + addOrgMembership, + getOrgMembership, + listOrgMembers, +} from '../../db/repositories/orgMembershipsRepository.js'; +import { + createUserWithMembership, deleteUser, deleteUserSessions, + getUserByEmail, getUserById, - listOrgUsers, updateUser, } from '../../db/repositories/usersRepository.js'; import { resolveActorRoleInOrg } from '../context.js'; @@ -91,14 +96,73 @@ function assertUserAccessAllowed( } } +/** + * A Postgres unique-constraint violation (e.g. the `users.email` unique index). + * + * drizzle wraps the driver error in a `DrizzleQueryError` whose `.cause` holds + * the original pg `DatabaseError` carrying `code: '23505'`, so walk the cause + * chain rather than only checking the top-level error. + */ +function isUniqueViolation(err: unknown): boolean { + let current: unknown = err; + for (let depth = 0; depth < 5 && current != null; depth++) { + if ( + typeof current === 'object' && + 'code' in current && + (current as { code?: unknown }).code === '23505' + ) { + return true; + } + current = (current as { cause?: unknown }).cause; + } + return false; +} + +/** + * Build a typed CONFLICT for a duplicate-email create (spec 021 plan 3, AC #2 — + * no more 500 on a re-used email). The message distinguishes the two cases an + * admin actually hits: + * + * - the email is already a member of *this* org → nothing to do; + * - the account exists but lives elsewhere → point them at `add-to-org`. + */ +async function buildDuplicateEmailConflict( + email: string, + effectiveOrgId: string, +): Promise { + const existing = await getUserByEmail(email); + if (!existing) { + // The email collided but the row is gone (rare race) — stay generic. + return new TRPCError({ + code: 'CONFLICT', + message: 'A user with this email already exists.', + }); + } + const membership = await getOrgMembership(existing.id, effectiveOrgId); + const alreadyMember = membership !== null || existing.orgId === effectiveOrgId; + if (alreadyMember) { + return new TRPCError({ + code: 'CONFLICT', + message: 'A user with this email is already a member of this organization.', + }); + } + return new TRPCError({ + code: 'CONFLICT', + message: `An account with this email already exists. Add it to this organization with \`cascade users add-to-org --email ${email}\`.`, + }); +} + export const usersRouter = router({ list: adminProcedure.query(async ({ ctx }) => { const actorRole = await resolveActorRole(ctx); assertOrgAdmin(actorRole); + // Membership-based listing (spec 021 plan 3, AC #5): the org's true + // membership, including accounts whose home org is elsewhere, each with + // their per-org role. A regular admin still never sees global superadmins. if (actorRole === 'superadmin') { - return listOrgUsers(ctx.effectiveOrgId); + return listOrgMembers(ctx.effectiveOrgId); } - return listOrgUsers(ctx.effectiveOrgId, { excludeRole: 'superadmin' }); + return listOrgMembers(ctx.effectiveOrgId, { excludeGlobalRole: 'superadmin' }); }), create: adminProcedure @@ -126,13 +190,77 @@ export const usersRouter = router({ const passwordHash = await bcrypt.hash(input.password, 10); - return createUser({ + // Membership roles are per-org ('member' | 'admin'); a global + // 'superadmin' maps to an 'admin' membership (mirrors the plan-1 + // backfill). The new account's home org is the effective org. + const membershipRole = role === 'superadmin' ? 'admin' : role; + + try { + return await createUserWithMembership({ + orgId: ctx.effectiveOrgId, + email: input.email, + name: input.name, + passwordHash, + role, + membershipRole, + }); + } catch (err) { + // Graceful duplicate-email handling (spec 021 plan 3, AC #2): + // turn the Postgres unique violation into a clear CONFLICT instead + // of a 500. Any other error propagates unchanged. + if (!isUniqueViolation(err)) throw err; + throw await buildDuplicateEmailConflict(input.email, ctx.effectiveOrgId); + } + }), + + /** + * Grant an existing account a membership in the effective org with a per-org + * role (spec 021 plan 3, AC #1). This is the additive admin capability the + * bug report needed: an org admin (or superadmin) can add a registered email + * to *their* org without creating a duplicate account. + * + * - The actor must be an admin/superadmin in the effective org (`adminProcedure` + * is refined by the per-org role, so an org admin switched into an org where + * they are only a member is denied — spec AC #8). + * - NOT_FOUND when no account owns the email (callers should `create` instead). + * - Idempotent: re-granting updates the per-org role rather than failing. + */ + addExistingUserToOrg: adminProcedure + .input( + z.object({ + email: z.string().email(), + role: z.enum(['member', 'admin']).optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const actorRole = await resolveActorRole(ctx); + assertOrgAdmin(actorRole); + + const role = input.role ?? 'member'; + + const existingUser = await getUserByEmail(input.email); + if (!existingUser) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: + 'No account exists with this email. Create the user first with `cascade users create`.', + }); + } + + const priorMembership = await getOrgMembership(existingUser.id, ctx.effectiveOrgId); + await addOrgMembership({ + userId: existingUser.id, orgId: ctx.effectiveOrgId, - email: input.email, - name: input.name, - passwordHash, role, }); + + return { + userId: existingUser.id, + email: existingUser.email, + orgId: ctx.effectiveOrgId, + role, + alreadyMember: priorMembership !== null, + }; }), update: adminProcedure diff --git a/src/cli/dashboard/users/add-to-org.ts b/src/cli/dashboard/users/add-to-org.ts new file mode 100644 index 000000000..ef473e50c --- /dev/null +++ b/src/cli/dashboard/users/add-to-org.ts @@ -0,0 +1,48 @@ +import { Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class UsersAddToOrg extends DashboardCommand { + static override description = + 'Add an existing user account to the current organization with a per-org role.'; + + static override examples = [ + '<%= config.bin %> users add-to-org --email alice@example.com', + '<%= config.bin %> users add-to-org --email alice@example.com --role admin', + ]; + + static override flags = { + ...DashboardCommand.baseFlags, + email: Flags.string({ description: 'Email of the existing account to add', required: true }), + role: Flags.string({ + description: 'Per-org role to grant (member, admin)', + options: ['member', 'admin'], + default: 'member', + }), + }; + + async run(): Promise { + const { flags } = await this.parse(UsersAddToOrg); + + try { + const result = await this.withSpinner('Adding user to organization...', () => + this.client.users.addExistingUserToOrg.mutate({ + email: flags.email, + role: flags.role as 'member' | 'admin' | undefined, + }), + ); + + if (flags.json) { + this.outputJson(result); + return; + } + + if (result.alreadyMember) { + this.success(`Updated ${result.email}'s role to ${result.role} in this organization`); + } else { + this.success(`Added ${result.email} to this organization as ${result.role}`); + } + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/db/repositories/orgMembershipsRepository.ts b/src/db/repositories/orgMembershipsRepository.ts index 1f863e574..0021c2976 100644 --- a/src/db/repositories/orgMembershipsRepository.ts +++ b/src/db/repositories/orgMembershipsRepository.ts @@ -1,14 +1,15 @@ -import { and, eq } from 'drizzle-orm'; +import { and, eq, ne } from 'drizzle-orm'; import { getDb } from '../client.js'; -import { organizations, orgMemberships } from '../schema/index.js'; +import { organizations, orgMemberships, users } from '../schema/index.js'; /** - * Read helpers for the multi-org membership model (spec 021, plan 2 of 4). + * Read + grant helpers for the multi-org membership model (spec 021). * - * Plan 1 shipped the `org_memberships` table dormant; plan 2 is the first - * consumer. These reads drive effective-org resolution, the active-org switch - * endpoint, and the per-org actor-role helper that user-management permission - * checks consume. Grant/create/list mutations land in plan 3. + * Plan 1 shipped the `org_memberships` table dormant; plan 2 added the reads + * that drive effective-org resolution, the active-org switch endpoint, and the + * per-org actor-role helper. Plan 3 adds the management surface: granting an + * existing account a membership (`addOrgMembership`) and listing an org's true + * membership (`listOrgMembers`). */ /** A user's membership in a single org. */ @@ -57,3 +58,75 @@ export async function listOrgMembershipsForUser(userId: string): Promise { + const db = getDb(); + const conditions = [eq(orgMemberships.orgId, orgId)]; + if (opts?.excludeGlobalRole !== undefined) { + conditions.push(ne(users.role, opts.excludeGlobalRole)); + } + return db + .select({ + id: users.id, + orgId: orgMemberships.orgId, + email: users.email, + name: users.name, + role: orgMemberships.role, + createdAt: users.createdAt, + updatedAt: users.updatedAt, + }) + .from(orgMemberships) + .innerJoin(users, eq(orgMemberships.userId, users.id)) + .where(and(...conditions)); +} + +/** + * Grant (or re-grant) a user a membership in an org with a per-org role + * (spec 021 plan 3). Idempotent: re-granting an existing membership updates the + * role rather than failing on the `(user_id, org_id)` unique index, so the + * grant mutation can be retried safely. + */ +export async function addOrgMembership(params: { + userId: string; + orgId: string; + role?: string; +}): Promise { + const db = getDb(); + const role = params.role ?? 'member'; + await db + .insert(orgMemberships) + .values({ userId: params.userId, orgId: params.orgId, role }) + .onConflictDoUpdate({ + target: [orgMemberships.userId, orgMemberships.orgId], + set: { role, updatedAt: new Date() }, + }); +} diff --git a/src/db/repositories/usersRepository.ts b/src/db/repositories/usersRepository.ts index d7e02e43d..658912069 100644 --- a/src/db/repositories/usersRepository.ts +++ b/src/db/repositories/usersRepository.ts @@ -1,6 +1,6 @@ import { and, eq, gt, lt, ne } from 'drizzle-orm'; import { getDb } from '../client.js'; -import { sessions, users } from '../schema/index.js'; +import { orgMemberships, sessions, users } from '../schema/index.js'; export interface DashboardUser { id: string; @@ -150,28 +150,45 @@ export async function listOrgUsers( } /** - * Create a new user. The passwordHash must be pre-hashed by the caller. - * Returns the new user's id. + * Create a new user AND their membership in the same org, atomically + * (spec 021 plan 3). The new account's home org is `orgId`; the membership + * mirrors it so the account immediately appears in the org's membership-based + * listing. The passwordHash must be pre-hashed by the caller. + * + * `membershipRole` is the PER-ORG role ('member' | 'admin'); callers map a + * global 'superadmin' to an 'admin' membership (membership roles are per-org). + * + * Both inserts run in one transaction, so a duplicate-email unique violation + * (`23505`) on the `users` insert rolls back without leaving an orphan + * membership. Returns the new user's id. */ -export async function createUser(params: { +export async function createUserWithMembership(params: { orgId: string; email: string; passwordHash: string; name: string; role: string; + membershipRole: string; }): Promise<{ id: string }> { const db = getDb(); - const [row] = await db - .insert(users) - .values({ + return db.transaction(async (tx) => { + const [row] = await tx + .insert(users) + .values({ + orgId: params.orgId, + email: params.email, + passwordHash: params.passwordHash, + name: params.name, + role: params.role, + }) + .returning({ id: users.id }); + await tx.insert(orgMemberships).values({ + userId: row.id, orgId: params.orgId, - email: params.email, - passwordHash: params.passwordHash, - name: params.name, - role: params.role, - }) - .returning({ id: users.id }); - return row; + role: params.membershipRole, + }); + return row; + }); } /** diff --git a/tests/integration/db/orgMembershipsManagement.test.ts b/tests/integration/db/orgMembershipsManagement.test.ts new file mode 100644 index 000000000..2bb3d67d7 --- /dev/null +++ b/tests/integration/db/orgMembershipsManagement.test.ts @@ -0,0 +1,162 @@ +/** + * Integration tests for the multi-org membership MANAGEMENT surface + * (spec 021, plan 3 of 4). + * + * Exercises the plan-3 mutations + listing against a real database: + * - addOrgMembership grant (idempotent re-grant updates the per-org role) + * - listOrgMembers membership-based listing (true membership across home orgs, + * per-org role, excludeGlobalRole filter) + * - createUserWithMembership atomic create (user + mirrored membership; a + * duplicate-email unique violation rolls back, leaving no orphan rows) + */ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + addOrgMembership, + getOrgMembership, + listOrgMembers, +} from '../../../src/db/repositories/orgMembershipsRepository.js'; +import { + createUserWithMembership, + getUserByEmail, +} from '../../../src/db/repositories/usersRepository.js'; +import { truncateAll } from '../helpers/db.js'; +import { seedMembership, seedOrg, seedUser } from '../helpers/seed.js'; + +describe('multi-org membership management (integration)', () => { + beforeEach(async () => { + await truncateAll(); + await seedOrg('home-org', 'Home Org'); + await seedOrg('other-org', 'Other Org'); + }); + + describe('addOrgMembership', () => { + it('grants a new membership with the requested role', async () => { + const user = await seedUser({ orgId: 'home-org', email: 'g@example.com', role: 'member' }); + + await addOrgMembership({ userId: user.id, orgId: 'other-org', role: 'admin' }); + + expect(await getOrgMembership(user.id, 'other-org')).toEqual({ + orgId: 'other-org', + role: 'admin', + }); + }); + + it('is idempotent: re-granting updates the per-org role without error', async () => { + const user = await seedUser({ orgId: 'home-org', email: 'h@example.com', role: 'member' }); + await seedMembership({ userId: user.id, orgId: 'other-org', role: 'member' }); + + // Re-grant with a different role — must not throw on the unique index. + await addOrgMembership({ userId: user.id, orgId: 'other-org', role: 'admin' }); + + expect(await getOrgMembership(user.id, 'other-org')).toEqual({ + orgId: 'other-org', + role: 'admin', + }); + // Still exactly one membership row for this (user, org). + const members = await listOrgMembers('other-org'); + expect(members.filter((m) => m.id === user.id)).toHaveLength(1); + }); + }); + + describe('listOrgMembers', () => { + it('returns the org true membership including accounts whose home org is elsewhere', async () => { + const local = await seedUser({ + orgId: 'other-org', + email: 'local@example.com', + role: 'member', + }); + await seedMembership({ userId: local.id, orgId: 'other-org', role: 'member' }); + + // A user whose HOME org is home-org but who is a member of other-org. + const guest = await seedUser({ + orgId: 'home-org', + email: 'guest@example.com', + role: 'member', + }); + await seedMembership({ userId: guest.id, orgId: 'other-org', role: 'admin' }); + + const members = await listOrgMembers('other-org'); + + expect(members).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: local.id, email: 'local@example.com', role: 'member' }), + // Per-org role (admin) wins over the guest's global role (member). + expect.objectContaining({ id: guest.id, email: 'guest@example.com', role: 'admin' }), + ]), + ); + expect(members).toHaveLength(2); + // orgId reflects the listed org, not the account's home org. + for (const m of members) { + expect(m.orgId).toBe('other-org'); + } + }); + + it('excludeGlobalRole hides accounts whose GLOBAL role matches', async () => { + const admin = await seedUser({ orgId: 'other-org', email: 'a@example.com', role: 'admin' }); + await seedMembership({ userId: admin.id, orgId: 'other-org', role: 'admin' }); + const sa = await seedUser({ + orgId: 'other-org', + email: 'sa@example.com', + role: 'superadmin', + }); + await seedMembership({ userId: sa.id, orgId: 'other-org', role: 'admin' }); + + const visible = await listOrgMembers('other-org', { excludeGlobalRole: 'superadmin' }); + expect(visible.map((m) => m.id)).toEqual([admin.id]); + + const all = await listOrgMembers('other-org'); + expect(all.map((m) => m.id).sort()).toEqual([admin.id, sa.id].sort()); + }); + + it('returns an empty array for an org with no members', async () => { + expect(await listOrgMembers('other-org')).toEqual([]); + }); + }); + + describe('createUserWithMembership', () => { + it('creates the account and a mirrored membership atomically', async () => { + const { id } = await createUserWithMembership({ + orgId: 'home-org', + email: 'new@example.com', + passwordHash: '$2b$10$hash', + name: 'New User', + role: 'admin', + membershipRole: 'admin', + }); + + // The account exists with its home org + global role… + const account = await getUserByEmail('new@example.com'); + expect(account?.id).toBe(id); + expect(account?.orgId).toBe('home-org'); + expect(account?.role).toBe('admin'); + + // …and immediately appears in the membership-based listing with the + // per-org role. + const members = await listOrgMembers('home-org'); + expect(members).toEqual([ + expect.objectContaining({ id, email: 'new@example.com', role: 'admin' }), + ]); + }); + + it('rolls back on a duplicate-email unique violation, leaving no orphan membership', async () => { + // Seed an account that owns the email in a DIFFERENT org. + await seedUser({ orgId: 'home-org', email: 'dupe@example.com', role: 'member' }); + + // drizzle wraps the pg DatabaseError in a DrizzleQueryError; the + // '23505' code lives on `.cause`. + await expect( + createUserWithMembership({ + orgId: 'other-org', + email: 'dupe@example.com', + passwordHash: '$2b$10$hash', + name: 'Dupe', + role: 'member', + membershipRole: 'member', + }), + ).rejects.toMatchObject({ cause: { code: '23505' } }); + + // The transaction rolled back: other-org gained no member. + expect(await listOrgMembers('other-org')).toEqual([]); + }); + }); +}); diff --git a/tests/unit/api/routers/users.test.ts b/tests/unit/api/routers/users.test.ts index 7bd1dfb4f..639c5823e 100644 --- a/tests/unit/api/routers/users.test.ts +++ b/tests/unit/api/routers/users.test.ts @@ -3,40 +3,47 @@ import { createMockSuperAdmin, createMockUser } from '../../../helpers/factories import { createCallerFor } from '../../../helpers/trpcTestHarness.js'; const { - mockListOrgUsers, - mockCreateUser, + mockListOrgMembers, + mockCreateUserWithMembership, mockUpdateUser, mockDeleteUser, mockGetUserById, + mockGetUserByEmail, mockDeleteUserSessions, mockBcryptHash, mockGetOrgMembership, + mockAddOrgMembership, } = vi.hoisted(() => ({ - mockListOrgUsers: vi.fn(), - mockCreateUser: vi.fn(), + mockListOrgMembers: vi.fn(), + mockCreateUserWithMembership: vi.fn(), mockUpdateUser: vi.fn(), mockDeleteUser: vi.fn(), mockGetUserById: vi.fn(), + mockGetUserByEmail: vi.fn(), mockDeleteUserSessions: vi.fn(), mockBcryptHash: vi.fn(), mockGetOrgMembership: vi.fn(), + mockAddOrgMembership: vi.fn(), })); vi.mock('../../../../src/db/repositories/usersRepository.js', () => ({ - listOrgUsers: mockListOrgUsers, - createUser: mockCreateUser, + createUserWithMembership: mockCreateUserWithMembership, updateUser: mockUpdateUser, deleteUser: mockDeleteUser, getUserById: mockGetUserById, + getUserByEmail: mockGetUserByEmail, deleteUserSessions: mockDeleteUserSessions, })); // Per-org actor-role helper (spec 021 plan 2) reads memberships through this // repository. Default: no membership row → resolveActorRoleInOrg falls back to // the global role for the home org, so the existing admin/member/superadmin -// tests (which act in their home org) are unaffected. +// tests (which act in their home org) are unaffected. Plan 3 adds the +// membership-based listing + grant mutation, also mocked here. vi.mock('../../../../src/db/repositories/orgMembershipsRepository.js', () => ({ getOrgMembership: mockGetOrgMembership, + listOrgMembers: mockListOrgMembers, + addOrgMembership: mockAddOrgMembership, })); vi.mock('bcrypt', () => ({ @@ -57,14 +64,15 @@ describe('usersRouter', () => { beforeEach(() => { mockBcryptHash.mockResolvedValue('hashed-password'); mockDeleteUserSessions.mockResolvedValue(undefined); + mockAddOrgMembership.mockResolvedValue(undefined); // Default: caller has no explicit membership row, so the per-org role // resolver falls back to the global role for their home org. mockGetOrgMembership.mockResolvedValue(null); }); describe('list', () => { - it('returns org-scoped user list without passwordHash (admin caller excludes superadmins)', async () => { - const orgUsers = [ + it('returns the org membership list without passwordHash (admin caller excludes global superadmins)', async () => { + const orgMembers = [ { id: 'user-1', orgId: 'org-1', @@ -84,20 +92,20 @@ describe('usersRouter', () => { updatedAt: null, }, ]; - mockListOrgUsers.mockResolvedValue(orgUsers); + mockListOrgMembers.mockResolvedValue(orgMembers); const caller = createCaller({ user: mockAdminUser, effectiveOrgId: mockAdminUser.orgId }); const result = await caller.list(); - expect(mockListOrgUsers).toHaveBeenCalledWith('org-1', { excludeRole: 'superadmin' }); - expect(result).toEqual(orgUsers); - // Note: passwordHash exclusion is enforced at the repository layer (listOrgUsers selects + expect(mockListOrgMembers).toHaveBeenCalledWith('org-1', { excludeGlobalRole: 'superadmin' }); + expect(result).toEqual(orgMembers); + // Note: passwordHash exclusion is enforced at the repository layer (listOrgMembers selects // specific columns). The mock already returns data without passwordHash, reflecting // the contract that the repository never returns this field. }); - it('superadmin caller receives full user list including superadmins', async () => { - const orgUsers = [ + it('superadmin caller receives the full membership list including global superadmins', async () => { + const orgMembers = [ { id: 'user-1', orgId: 'org-1', @@ -112,22 +120,22 @@ describe('usersRouter', () => { orgId: 'org-1', email: 'super@example.com', name: 'Super', - role: 'superadmin', + role: 'admin', createdAt: null, updatedAt: null, }, ]; - mockListOrgUsers.mockResolvedValue(orgUsers); + mockListOrgMembers.mockResolvedValue(orgMembers); const caller = createCaller({ user: mockSuperAdmin, effectiveOrgId: mockSuperAdmin.orgId }); const result = await caller.list(); - expect(mockListOrgUsers).toHaveBeenCalledWith('org-1'); - expect(result).toEqual(orgUsers); + expect(mockListOrgMembers).toHaveBeenCalledWith('org-1'); + expect(result).toEqual(orgMembers); }); - it('returns empty array when no users', async () => { - mockListOrgUsers.mockResolvedValue([]); + it('returns empty array when no members', async () => { + mockListOrgMembers.mockResolvedValue([]); const caller = createCaller({ user: mockAdminUser, effectiveOrgId: mockAdminUser.orgId }); const result = await caller.list(); @@ -146,8 +154,8 @@ describe('usersRouter', () => { }); describe('create', () => { - it('creates user with hashed password', async () => { - mockCreateUser.mockResolvedValue({ id: 'new-user-1' }); + it('creates user with hashed password and a mirrored membership', async () => { + mockCreateUserWithMembership.mockResolvedValue({ id: 'new-user-1' }); const caller = createCaller({ user: mockAdminUser, effectiveOrgId: mockAdminUser.orgId }); const result = await caller.create({ @@ -157,18 +165,19 @@ describe('usersRouter', () => { }); expect(mockBcryptHash).toHaveBeenCalledWith('secret123456789', 10); - expect(mockCreateUser).toHaveBeenCalledWith({ + expect(mockCreateUserWithMembership).toHaveBeenCalledWith({ orgId: 'org-1', email: 'newuser@example.com', name: 'New User', passwordHash: 'hashed-password', role: 'member', + membershipRole: 'member', }); expect(result).toEqual({ id: 'new-user-1' }); }); - it('creates admin user when role is specified', async () => { - mockCreateUser.mockResolvedValue({ id: 'new-admin-1' }); + it('creates admin user with an admin membership when role is specified', async () => { + mockCreateUserWithMembership.mockResolvedValue({ id: 'new-admin-1' }); const caller = createCaller({ user: mockAdminUser, effectiveOrgId: mockAdminUser.orgId }); await caller.create({ @@ -178,7 +187,9 @@ describe('usersRouter', () => { role: 'admin', }); - expect(mockCreateUser).toHaveBeenCalledWith(expect.objectContaining({ role: 'admin' })); + expect(mockCreateUserWithMembership).toHaveBeenCalledWith( + expect.objectContaining({ role: 'admin', membershipRole: 'admin' }), + ); }); it('rejects superadmin role assignment when caller is not superadmin (FORBIDDEN)', async () => { @@ -193,11 +204,11 @@ describe('usersRouter', () => { }), ).rejects.toMatchObject({ code: 'FORBIDDEN' }); - expect(mockCreateUser).not.toHaveBeenCalled(); + expect(mockCreateUserWithMembership).not.toHaveBeenCalled(); }); - it('allows superadmin to create superadmin users', async () => { - mockCreateUser.mockResolvedValue({ id: 'new-super-1' }); + it('allows superadmin to create superadmin users, mapping membership to admin', async () => { + mockCreateUserWithMembership.mockResolvedValue({ id: 'new-super-1' }); const caller = createCaller({ user: mockSuperAdmin, effectiveOrgId: mockSuperAdmin.orgId }); await caller.create({ @@ -207,7 +218,9 @@ describe('usersRouter', () => { role: 'superadmin', }); - expect(mockCreateUser).toHaveBeenCalledWith(expect.objectContaining({ role: 'superadmin' })); + expect(mockCreateUserWithMembership).toHaveBeenCalledWith( + expect.objectContaining({ role: 'superadmin', membershipRole: 'admin' }), + ); }); it('rejects password shorter than 12 characters', async () => { @@ -217,20 +230,20 @@ describe('usersRouter', () => { caller.create({ email: 'x@example.com', name: 'X', password: 'short' }), ).rejects.toThrow(); - expect(mockCreateUser).not.toHaveBeenCalled(); + expect(mockCreateUserWithMembership).not.toHaveBeenCalled(); }); it('accepts password of exactly 12 characters', async () => { - mockCreateUser.mockResolvedValue({ id: 'new-user-1' }); + mockCreateUserWithMembership.mockResolvedValue({ id: 'new-user-1' }); const caller = createCaller({ user: mockAdminUser, effectiveOrgId: mockAdminUser.orgId }); await caller.create({ email: 'x@example.com', name: 'X', password: 'exactly12chr' }); - expect(mockCreateUser).toHaveBeenCalled(); + expect(mockCreateUserWithMembership).toHaveBeenCalled(); }); it('accepts password longer than 12 characters', async () => { - mockCreateUser.mockResolvedValue({ id: 'new-user-2' }); + mockCreateUserWithMembership.mockResolvedValue({ id: 'new-user-2' }); const caller = createCaller({ user: mockAdminUser, effectiveOrgId: mockAdminUser.orgId }); await caller.create({ @@ -239,7 +252,7 @@ describe('usersRouter', () => { password: 'this-is-a-very-long-password-123', }); - expect(mockCreateUser).toHaveBeenCalled(); + expect(mockCreateUserWithMembership).toHaveBeenCalled(); }); it('throws UNAUTHORIZED when not authenticated', async () => { @@ -255,6 +268,186 @@ describe('usersRouter', () => { caller.create({ email: 'x@x.com', name: 'X', password: 'x' }), ).rejects.toMatchObject({ code: 'FORBIDDEN' }); }); + + // ================================================================= + // Graceful duplicate-email create (spec 021 plan 3, AC #2 — no 500) + // ================================================================= + it('maps a duplicate-email unique violation to CONFLICT — already a member here', async () => { + mockCreateUserWithMembership.mockRejectedValue( + Object.assign(new Error('duplicate key value'), { code: '23505' }), + ); + // The existing account's home org IS this org → already a member here. + // Leave getOrgMembership at its default (null) so the admin actor still + // resolves to admin in their home org. + mockGetUserByEmail.mockResolvedValue({ id: 'existing-1', orgId: 'org-1', role: 'member' }); + const caller = createCaller({ user: mockAdminUser, effectiveOrgId: mockAdminUser.orgId }); + + await expect( + caller.create({ + email: 'dupe@example.com', + name: 'Dupe', + password: 'secret123456789', + }), + ).rejects.toMatchObject({ + code: 'CONFLICT', + message: expect.stringContaining('already a member'), + }); + }); + + it('maps a duplicate-email for an out-of-org account to CONFLICT — add-to-org guidance', async () => { + // Realistic drizzle shape: DrizzleQueryError wrapping the pg error on + // `.cause`. isUniqueViolation must walk the cause chain. + mockCreateUserWithMembership.mockRejectedValue( + Object.assign(new Error('Failed query'), { + cause: Object.assign(new Error('duplicate key value'), { code: '23505' }), + }), + ); + // Account exists but its home org is elsewhere and it has no membership here. + mockGetUserByEmail.mockResolvedValue({ + id: 'existing-2', + orgId: 'other-org', + role: 'member', + }); + mockGetOrgMembership.mockResolvedValue(null); + const caller = createCaller({ user: mockAdminUser, effectiveOrgId: mockAdminUser.orgId }); + + await expect( + caller.create({ + email: 'elsewhere@example.com', + name: 'Elsewhere', + password: 'secret123456789', + }), + ).rejects.toMatchObject({ + code: 'CONFLICT', + message: expect.stringContaining('add-to-org'), + }); + }); + + it('rethrows non-unique-violation errors unchanged', async () => { + mockCreateUserWithMembership.mockRejectedValue(new Error('connection reset')); + const caller = createCaller({ user: mockAdminUser, effectiveOrgId: mockAdminUser.orgId }); + + await expect( + caller.create({ email: 'x@example.com', name: 'X', password: 'secret123456789' }), + ).rejects.toThrow('connection reset'); + }); + }); + + // ===================================================================== + // addExistingUserToOrg grant mutation (spec 021 plan 3, AC #1) + // ===================================================================== + describe('addExistingUserToOrg', () => { + it('grants an existing account a membership in the effective org', async () => { + mockGetUserByEmail.mockResolvedValue({ + id: 'existing-1', + orgId: 'other-org', + email: 'alice@example.com', + role: 'member', + }); + mockGetOrgMembership.mockResolvedValue(null); + const caller = createCaller({ user: mockAdminUser, effectiveOrgId: mockAdminUser.orgId }); + + const result = await caller.addExistingUserToOrg({ + email: 'alice@example.com', + role: 'admin', + }); + + expect(mockAddOrgMembership).toHaveBeenCalledWith({ + userId: 'existing-1', + orgId: 'org-1', + role: 'admin', + }); + expect(result).toEqual({ + userId: 'existing-1', + email: 'alice@example.com', + orgId: 'org-1', + role: 'admin', + alreadyMember: false, + }); + }); + + it('defaults the granted role to member', async () => { + mockGetUserByEmail.mockResolvedValue({ + id: 'existing-1', + orgId: 'other-org', + email: 'alice@example.com', + role: 'member', + }); + mockGetOrgMembership.mockResolvedValue(null); + const caller = createCaller({ user: mockAdminUser, effectiveOrgId: mockAdminUser.orgId }); + + const result = await caller.addExistingUserToOrg({ email: 'alice@example.com' }); + + expect(mockAddOrgMembership).toHaveBeenCalledWith( + expect.objectContaining({ role: 'member' }), + ); + expect(result.role).toBe('member'); + }); + + it('is idempotent: re-granting an existing membership reports alreadyMember', async () => { + mockGetUserByEmail.mockResolvedValue({ + id: 'existing-1', + orgId: 'org-1', + email: 'alice@example.com', + role: 'member', + }); + // Per-org resolver consults membership for the actor; default null is + // fine (admin acting in home org). The grant target's prior membership + // is the second lookup — return an existing row. + mockGetOrgMembership.mockResolvedValueOnce(null).mockResolvedValueOnce({ + orgId: 'org-1', + role: 'member', + }); + const caller = createCaller({ user: mockAdminUser, effectiveOrgId: mockAdminUser.orgId }); + + const result = await caller.addExistingUserToOrg({ + email: 'alice@example.com', + role: 'admin', + }); + + expect(result.alreadyMember).toBe(true); + expect(mockAddOrgMembership).toHaveBeenCalledWith({ + userId: 'existing-1', + orgId: 'org-1', + role: 'admin', + }); + }); + + it('throws NOT_FOUND when no account owns the email', async () => { + mockGetUserByEmail.mockResolvedValue(null); + const caller = createCaller({ user: mockAdminUser, effectiveOrgId: mockAdminUser.orgId }); + + await expect( + caller.addExistingUserToOrg({ email: 'ghost@example.com' }), + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + expect(mockAddOrgMembership).not.toHaveBeenCalled(); + }); + + it('throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null, effectiveOrgId: null }); + await expect(caller.addExistingUserToOrg({ email: 'a@example.com' })).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + + it('throws FORBIDDEN when caller is a member', async () => { + const caller = createCaller({ user: mockMember, effectiveOrgId: mockMember.orgId }); + await expect(caller.addExistingUserToOrg({ email: 'a@example.com' })).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + expect(mockAddOrgMembership).not.toHaveBeenCalled(); + }); + + it('denies an org admin switched to an org where they are only a member (AC #8)', async () => { + mockGetOrgMembership.mockResolvedValue({ orgId: 'org-2', role: 'member' }); + const caller = createCaller({ user: mockAdminUser, effectiveOrgId: 'org-2' }); + + await expect( + caller.addExistingUserToOrg({ email: 'a@example.com', role: 'admin' }), + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + expect(mockGetUserByEmail).not.toHaveBeenCalled(); + expect(mockAddOrgMembership).not.toHaveBeenCalled(); + }); }); describe('update', () => { @@ -544,18 +737,18 @@ describe('usersRouter', () => { const caller = createCaller({ user: mockAdminUser, effectiveOrgId: 'org-2' }); await expect(caller.list()).rejects.toMatchObject({ code: 'FORBIDDEN' }); - expect(mockListOrgUsers).not.toHaveBeenCalled(); + expect(mockListOrgMembers).not.toHaveBeenCalled(); expect(mockGetOrgMembership).toHaveBeenCalledWith('user-1', 'org-2'); }); it('list: an admin in the switched org lists that org with the per-org admin role (AC #4)', async () => { mockGetOrgMembership.mockResolvedValue({ orgId: 'org-2', role: 'admin' }); - mockListOrgUsers.mockResolvedValue([]); + mockListOrgMembers.mockResolvedValue([]); const caller = createCaller({ user: mockAdminUser, effectiveOrgId: 'org-2' }); await caller.list(); - expect(mockListOrgUsers).toHaveBeenCalledWith('org-2', { excludeRole: 'superadmin' }); + expect(mockListOrgMembers).toHaveBeenCalledWith('org-2', { excludeGlobalRole: 'superadmin' }); }); it('create: an org admin switched to a member-org cannot create users there (AC #8)', async () => { @@ -565,7 +758,7 @@ describe('usersRouter', () => { await expect( caller.create({ email: 'x@example.com', name: 'X', password: 'secret123456789' }), ).rejects.toMatchObject({ code: 'FORBIDDEN' }); - expect(mockCreateUser).not.toHaveBeenCalled(); + expect(mockCreateUserWithMembership).not.toHaveBeenCalled(); }); it('update: an org admin switched to a member-org cannot edit users there (AC #8)', async () => { diff --git a/tests/unit/cli/dashboard/users/users.test.ts b/tests/unit/cli/dashboard/users/users.test.ts index 8dbe69ef6..027ac2d04 100644 --- a/tests/unit/cli/dashboard/users/users.test.ts +++ b/tests/unit/cli/dashboard/users/users.test.ts @@ -22,6 +22,7 @@ vi.mock('chalk', () => ({ }, })); +import UsersAddToOrg from '../../../../../src/cli/dashboard/users/add-to-org.js'; import UsersCreate from '../../../../../src/cli/dashboard/users/create.js'; import UsersDelete from '../../../../../src/cli/dashboard/users/delete.js'; import UsersList from '../../../../../src/cli/dashboard/users/list.js'; @@ -47,6 +48,15 @@ function makeClient(overrides: Record = {}) { create: { mutate: vi.fn().mockResolvedValue(sampleUser) }, update: { mutate: vi.fn().mockResolvedValue(undefined) }, delete: { mutate: vi.fn().mockResolvedValue(undefined) }, + addExistingUserToOrg: { + mutate: vi.fn().mockResolvedValue({ + userId: 'user-uuid-123', + email: 'alice@example.com', + orgId: 'org-1', + role: 'member', + alreadyMember: false, + }), + }, }, ...overrides, }; @@ -320,3 +330,57 @@ describe('UsersDelete (delete)', () => { await expect(cmd.run()).rejects.toThrow(); }); }); + +// --------------------------------------------------------------------------- +// users add-to-org (spec 021 plan 3) +// --------------------------------------------------------------------------- +describe('UsersAddToOrg (add-to-org)', () => { + beforeEach(() => { + mockLoadConfig.mockReturnValue(baseConfig); + }); + + it('passes --email and default member role to addExistingUserToOrg.mutate', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new UsersAddToOrg(['--email', 'alice@example.com'], oclifConfig as never); + await cmd.run(); + + expect(client.users.addExistingUserToOrg.mutate).toHaveBeenCalledWith({ + email: 'alice@example.com', + role: 'member', + }); + }); + + it('passes the --role flag through to the mutation', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new UsersAddToOrg( + ['--email', 'alice@example.com', '--role', 'admin'], + oclifConfig as never, + ); + await cmd.run(); + + expect(client.users.addExistingUserToOrg.mutate).toHaveBeenCalledWith({ + email: 'alice@example.com', + role: 'admin', + }); + }); + + it('outputs json when --json flag is set', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new UsersAddToOrg(['--email', 'alice@example.com', '--json'], oclifConfig as never); + await expect(cmd.run()).resolves.toBeUndefined(); + expect(client.users.addExistingUserToOrg.mutate).toHaveBeenCalled(); + }); + + it('requires the --email flag', async () => { + mockCreateDashboardClient.mockReturnValue(makeClient()); + + const cmd = new UsersAddToOrg([], oclifConfig as never); + await expect(cmd.run()).rejects.toThrow(); + }); +}); diff --git a/tests/unit/db/repositories/orgMembershipsRepository.test.ts b/tests/unit/db/repositories/orgMembershipsRepository.test.ts index 9c6369a1c..079394120 100644 --- a/tests/unit/db/repositories/orgMembershipsRepository.test.ts +++ b/tests/unit/db/repositories/orgMembershipsRepository.test.ts @@ -15,10 +15,21 @@ vi.mock('../../../../src/db/schema/index.js', () => ({ id: 'id', name: 'name', }, + users: { + id: 'id', + orgId: 'org_id', + email: 'email', + name: 'name', + role: 'role', + createdAt: 'created_at', + updatedAt: 'updated_at', + }, })); import { + addOrgMembership, getOrgMembership, + listOrgMembers, listOrgMembershipsForUser, } from '../../../../src/db/repositories/orgMembershipsRepository.js'; @@ -26,7 +37,7 @@ describe('orgMembershipsRepository', () => { let mockDb: ReturnType; beforeEach(() => { - mockDb = createMockDbWithGetDb(); + mockDb = createMockDbWithGetDb({ withUpsert: true }); }); describe('getOrgMembership', () => { @@ -65,4 +76,77 @@ describe('orgMembershipsRepository', () => { expect(result).toEqual([]); }); }); + + describe('listOrgMembers', () => { + it('returns the org membership joined with each account, using the per-org role', async () => { + const rows = [ + { + id: 'user-1', + orgId: 'org-2', + email: 'alice@example.com', + name: 'Alice', + role: 'admin', + createdAt: null, + updatedAt: null, + }, + { + id: 'user-2', + orgId: 'org-2', + email: 'bob@example.com', + name: 'Bob', + role: 'member', + createdAt: null, + updatedAt: null, + }, + ]; + mockDb.chain.where.mockResolvedValueOnce(rows); + + const result = await listOrgMembers('org-2'); + expect(result).toEqual(rows); + expect(mockDb.chain.innerJoin).toHaveBeenCalled(); + }); + + it('applies the excludeGlobalRole filter when provided', async () => { + mockDb.chain.where.mockResolvedValueOnce([]); + + await listOrgMembers('org-2', { excludeGlobalRole: 'superadmin' }); + // The join + filtered where is the terminal; both conditions land there. + expect(mockDb.chain.innerJoin).toHaveBeenCalled(); + expect(mockDb.chain.where).toHaveBeenCalledTimes(1); + }); + + it('returns an empty array when the org has no members', async () => { + mockDb.chain.where.mockResolvedValueOnce([]); + + const result = await listOrgMembers('empty-org'); + expect(result).toEqual([]); + }); + }); + + describe('addOrgMembership', () => { + it('upserts the membership with the given role (idempotent re-grant)', async () => { + await addOrgMembership({ userId: 'user-1', orgId: 'org-2', role: 'admin' }); + + expect(mockDb.db.insert).toHaveBeenCalledTimes(1); + expect(mockDb.chain.values).toHaveBeenCalledWith({ + userId: 'user-1', + orgId: 'org-2', + role: 'admin', + }); + expect(mockDb.chain.onConflictDoUpdate).toHaveBeenCalledTimes(1); + const setArg = mockDb.chain.onConflictDoUpdate.mock.calls[0][0].set; + expect(setArg.role).toBe('admin'); + expect(setArg.updatedAt).toBeInstanceOf(Date); + }); + + it('defaults the role to member when omitted', async () => { + await addOrgMembership({ userId: 'user-1', orgId: 'org-2' }); + + expect(mockDb.chain.values).toHaveBeenCalledWith({ + userId: 'user-1', + orgId: 'org-2', + role: 'member', + }); + }); + }); }); diff --git a/tests/unit/db/repositories/usersRepository.test.ts b/tests/unit/db/repositories/usersRepository.test.ts index ee3c74360..3b10d574a 100644 --- a/tests/unit/db/repositories/usersRepository.test.ts +++ b/tests/unit/db/repositories/usersRepository.test.ts @@ -22,11 +22,17 @@ vi.mock('../../../../src/db/schema/index.js', () => ({ expiresAt: 'expires_at', activeOrgId: 'active_org_id', }, + orgMemberships: { + id: 'id', + userId: 'user_id', + orgId: 'org_id', + role: 'role', + }, })); import { createSession, - createUser, + createUserWithMembership, deleteExpiredSessions, deleteSession, deleteUser, @@ -218,39 +224,71 @@ describe('usersRepository', () => { }); }); - describe('createUser', () => { - it('inserts user and returns id', async () => { - mockDb.chain.returning.mockResolvedValueOnce([{ id: 'new-user-uuid' }]); - - const result = await createUser({ + describe('createUserWithMembership', () => { + /** + * createUserWithMembership runs both inserts inside db.transaction(). Mock + * the transaction to invoke its callback with a tx whose insert chain we + * can assert on (mirrors the agentTriggerConfigs transaction test pattern). + */ + function mockTransaction() { + const txChain: Record> = {}; + txChain.returning = vi.fn().mockResolvedValue([{ id: 'new-user-uuid' }]); + txChain.values = vi.fn().mockReturnValue({ returning: txChain.returning }); + const txInsert = vi.fn().mockReturnValue({ values: txChain.values }); + const tx = { insert: txInsert }; + (mockDb.db as unknown as Record).transaction = vi + .fn() + .mockImplementation(async (fn: (tx: unknown) => Promise) => fn(tx)); + return { txChain, txInsert }; + } + + it('inserts the user and the membership and returns the new id', async () => { + const { txChain, txInsert } = mockTransaction(); + + const result = await createUserWithMembership({ orgId: 'org-1', email: 'newuser@example.com', passwordHash: '$2b$10$hashed', name: 'New User', role: 'member', + membershipRole: 'member', }); expect(result).toEqual({ id: 'new-user-uuid' }); - expect(mockDb.db.insert).toHaveBeenCalledTimes(1); + // One insert for users, one for org_memberships — same transaction. + expect(txInsert).toHaveBeenCalledTimes(2); + expect(txChain.values).toHaveBeenCalledWith({ + orgId: 'org-1', + email: 'newuser@example.com', + passwordHash: '$2b$10$hashed', + name: 'New User', + role: 'member', + }); + expect(txChain.values).toHaveBeenCalledWith({ + userId: 'new-user-uuid', + orgId: 'org-1', + role: 'member', + }); }); - it('stores pre-hashed password without modification', async () => { - mockDb.chain.returning.mockResolvedValueOnce([{ id: 'u1' }]); - const hashedPassword = '$2b$10$somehash'; + it('grants the membership with the mapped per-org role (superadmin → admin)', async () => { + const { txChain } = mockTransaction(); - await createUser({ + await createUserWithMembership({ orgId: 'org-1', - email: 'test@example.com', - passwordHash: hashedPassword, - name: 'Test User', - role: 'admin', + email: 'super@example.com', + passwordHash: '$2b$10$somehash', + name: 'Super User', + role: 'superadmin', + membershipRole: 'admin', }); - expect(mockDb.chain.values).toHaveBeenCalledWith({ + // users insert keeps the global role… + expect(txChain.values).toHaveBeenCalledWith(expect.objectContaining({ role: 'superadmin' })); + // …while the membership gets the per-org role. + expect(txChain.values).toHaveBeenCalledWith({ + userId: 'new-user-uuid', orgId: 'org-1', - email: 'test@example.com', - passwordHash: hashedPassword, - name: 'Test User', role: 'admin', }); }); From 4f7a89457cfafd6b878a5417758f9421c72a9da9 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Wed, 24 Jun 2026 19:10:04 +0000 Subject: [PATCH 05/18] fix(users): reconcile member listing roles + backfill membership gaps (PR #1441 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review concerns on spec 021 plan 3: - BLOCKING: `users.list` returned the per-org membership role while the live Settings → Users editor still reads/writes the global `users.role`, so the editor could silently revert a user's global role and the superadmin "Manage via CLI" guard stopped matching. `listOrgMembers` now returns BOTH `role` (per-org) and `globalRole`; the table badge + CLI guard + editor target the global role, eliminating the drift until the plan-4 UI reconciliation. - SHOULD_FIX: the membership-based listing inner-joins `org_memberships`, so accounts created via the old `createUser` (and bootstrap superadmins) had no membership row and vanished from their own org. Re-run the idempotent home-org backfill as migration 0054, and mirror a membership in `tools/create-admin-user.ts`. - Remove the now-dead `listOrgUsers` / `OrgUser` (only its own test referenced it) per the review question. Co-Authored-By: Claude Opus 4.8 --- docs/architecture/09-database.md | 2 +- .../0054_org_memberships_backfill.sql | 31 ++++++++++ src/db/migrations/meta/_journal.json | 7 +++ .../repositories/orgMembershipsRepository.ts | 15 ++++- src/db/repositories/usersRepository.ts | 37 ------------ .../db/orgMembershipsBackfill.test.ts | 59 +++++++++++++++++-- .../db/orgMembershipsManagement.test.ts | 18 +++++- tests/unit/api/routers/users.test.ts | 4 ++ .../db/repositories/usersRepository.test.ts | 52 ---------------- tools/create-admin-user.ts | 18 +++++- .../components/settings/user-form-dialog.tsx | 9 ++- web/src/components/settings/users-table.tsx | 11 +++- 12 files changed, 157 insertions(+), 106 deletions(-) create mode 100644 src/db/migrations/0054_org_memberships_backfill.sql diff --git a/docs/architecture/09-database.md b/docs/architecture/09-database.md index 76a3bcee0..9b25b5369 100644 --- a/docs/architecture/09-database.md +++ b/docs/architecture/09-database.md @@ -147,7 +147,7 @@ erDiagram | `pr_work_items` | Maps PRs and external alert sources to PM work items for run-link display and alert idempotency | Partial unique indexes on `(project_id, pr_number)`, `(project_id, work_item_id)`, and `(project_id, external_source, external_id)` when those values are present | | `webhook_logs` | Raw webhook payloads for debugging | — | | `users` | Dashboard users (email, bcrypt hash, role) | Org-scoped (`org_id` = home org, `role` = global role) | -| `org_memberships` | Multi-org membership: links a user to an org with a per-org role, so one account can belong to many orgs. `users.org_id`/`users.role` remain the home org + global role. Read by effective-org resolution + the per-org actor-role helper (spec 021 plan 2); written by the grant mutation (`users.addExistingUserToOrg`) and the membership-mirroring create, and read by membership-based member listing (spec 021 plan 3). | UNIQUE(`user_id`, `org_id`) | +| `org_memberships` | Multi-org membership: links a user to an org with a per-org role, so one account can belong to many orgs. `users.org_id`/`users.role` remain the home org + global role. Read by effective-org resolution + the per-org actor-role helper (spec 021 plan 2); written by the grant mutation (`users.addExistingUserToOrg`) and the membership-mirroring create, and read by membership-based member listing (spec 021 plan 3). The listing returns BOTH the per-org `role` and the global `users.role` so the Settings → Users editor keeps targeting the global role until the plan-4 UI reconciliation. The idempotent home-org backfill runs in migration `0053` and is re-run by `0054` so accounts created via the old `createUser` (and bootstrap superadmins) never vanish from the inner-join listing. | UNIQUE(`user_id`, `org_id`) | | `sessions` | Session tokens for cookie auth (30-day expiry); `active_org_id` (nullable) tracks the org the session is currently acting in for multi-org | — | | `debug_analyses` | AI debug analysis results | — | diff --git a/src/db/migrations/0054_org_memberships_backfill.sql b/src/db/migrations/0054_org_memberships_backfill.sql new file mode 100644 index 000000000..a7ed3a83e --- /dev/null +++ b/src/db/migrations/0054_org_memberships_backfill.sql @@ -0,0 +1,31 @@ +-- 0054_org_memberships_backfill.sql +-- Multi-org membership (spec 021, plan 3 of 4): re-run the idempotent +-- one-membership-per-user backfill from migration 0053. +-- +-- Why a second backfill? The org member listing (`users.list` → +-- `listOrgMembers`) inner-joins `org_memberships`, so only accounts with a +-- membership row appear. Membership rows are guaranteed from the plan-1 0053 +-- backfill snapshot and the new `createUserWithMembership`, but NOT for: +-- * accounts created via the old `createUser` between the 0053 backfill and +-- this deploy, and +-- * bootstrap superadmins inserted directly by `tools/create-admin-user.ts`. +-- Those accounts would silently vanish from their own org's listing (PR #1441 +-- review). Re-running the backfill mirrors a home-org membership for every such +-- account, closing the window cheaply. +-- +-- Idempotent via ON CONFLICT on the (user_id, org_id) unique index — accounts +-- that already have a membership are untouched, including any whose per-org role +-- diverged from the global role (the grant mutation owns those). Maps the global +-- 'superadmin' role to an 'admin' membership, mirroring 0053. Forward-only. + +BEGIN; + +INSERT INTO org_memberships (user_id, org_id, role) +SELECT + u.id, + u.org_id, + CASE WHEN u.role = 'superadmin' THEN 'admin' ELSE u.role END +FROM users u +ON CONFLICT (user_id, org_id) DO NOTHING; + +COMMIT; diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index c134f1f2e..05d220c90 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -379,6 +379,13 @@ "when": 1788000000000, "tag": "0053_org_memberships", "breakpoints": false + }, + { + "idx": 54, + "version": "7", + "when": 1789000000000, + "tag": "0054_org_memberships_backfill", + "breakpoints": false } ] } diff --git a/src/db/repositories/orgMembershipsRepository.ts b/src/db/repositories/orgMembershipsRepository.ts index 0021c2976..91db54a44 100644 --- a/src/db/repositories/orgMembershipsRepository.ts +++ b/src/db/repositories/orgMembershipsRepository.ts @@ -60,9 +60,15 @@ export async function listOrgMembershipsForUser(userId: string): Promise { - const db = getDb(); - const conditions = [eq(users.orgId, orgId)]; - if (opts?.excludeRole !== undefined) { - conditions.push(ne(users.role, opts.excludeRole)); - } - return db - .select({ - id: users.id, - orgId: users.orgId, - email: users.email, - name: users.name, - role: users.role, - createdAt: users.createdAt, - updatedAt: users.updatedAt, - }) - .from(users) - .where(and(...conditions)); -} - /** * Create a new user AND their membership in the same org, atomically * (spec 021 plan 3). The new account's home org is `orgId`; the membership diff --git a/tests/integration/db/orgMembershipsBackfill.test.ts b/tests/integration/db/orgMembershipsBackfill.test.ts index 84f21695d..739e73009 100644 --- a/tests/integration/db/orgMembershipsBackfill.test.ts +++ b/tests/integration/db/orgMembershipsBackfill.test.ts @@ -27,14 +27,17 @@ import { seedOrg, seedSession, seedUser } from '../helpers/seed.js'; const MIGRATION_PATH = fileURLToPath( new URL('../../../src/db/migrations/0053_org_memberships.sql', import.meta.url), ); +const BACKFILL_RERUN_PATH = fileURLToPath( + new URL('../../../src/db/migrations/0054_org_memberships_backfill.sql', import.meta.url), +); /** - * Re-runs the migration body minus its transaction wrappers (drizzle's raw sql - * tag runs inside its own connection). The migration is fully idempotent, so - * re-running after seeding users is safe. + * Re-runs a migration body minus its transaction wrappers (drizzle's raw sql + * tag runs inside its own connection). These backfill migrations are fully + * idempotent, so re-running after seeding users is safe. */ -async function runMigration(): Promise { - const migrationText = await readFile(MIGRATION_PATH, 'utf-8'); +async function runMigrationFile(path: string): Promise { + const migrationText = await readFile(path, 'utf-8'); const body = migrationText .split('\n') .filter((line) => !/^\s*(BEGIN|COMMIT)\s*;\s*$/i.test(line)) @@ -42,6 +45,10 @@ async function runMigration(): Promise { await getDb().execute(sql.raw(body)); } +async function runMigration(): Promise { + await runMigrationFile(MIGRATION_PATH); +} + async function listMemberships() { return getDb() .select({ @@ -180,4 +187,46 @@ describe('migration 0053 — org_memberships table + active-org + backfill', () ).rejects.toThrow(); }); }); + + // Migration 0054 re-runs the idempotent backfill so accounts created via the + // old `createUser` (or the bootstrap superadmin tool) between the 0053 snapshot + // and the plan-3 deploy gain a home-org membership and stop vanishing from the + // membership-based listing (PR #1441 review). + describe('migration 0054 — backfill re-run heals membership-less accounts', () => { + it('mirrors a home-org membership for an account that has none', async () => { + // A user seeded WITHOUT a membership row simulates the gap: an account + // created via the old createUser after the 0053 backfill snapshot. + const orphan = await seedUser({ email: 'orphan@example.com', role: 'member' }); + expect(await listMemberships()).toHaveLength(0); + + await runMigrationFile(BACKFILL_RERUN_PATH); + + const byUser = new Map((await listMemberships()).map((m) => [m.userId, m])); + expect(byUser.get(orphan.id)).toMatchObject({ orgId: 'test-org', role: 'member' }); + }); + + it('maps a membership-less global superadmin to an admin membership', async () => { + const sa = await seedUser({ email: 'boot-super@example.com', role: 'superadmin' }); + + await runMigrationFile(BACKFILL_RERUN_PATH); + + const [membership] = await listMemberships(); + expect(membership).toMatchObject({ userId: sa.id, orgId: 'test-org', role: 'admin' }); + }); + + it('leaves an existing diverged per-org role untouched (grant mutation owns it)', async () => { + // Account is a home-org member globally but was granted 'admin' here. + const user = await seedUser({ email: 'granted@example.com', role: 'member' }); + await getDb() + .insert(orgMemberships) + .values({ userId: user.id, orgId: 'test-org', role: 'admin' }); + + await runMigrationFile(BACKFILL_RERUN_PATH); + + // ON CONFLICT DO NOTHING — the diverged per-org admin role survives. + const [membership] = await listMemberships(); + expect(membership).toMatchObject({ userId: user.id, role: 'admin' }); + expect(await listMemberships()).toHaveLength(1); + }); + }); }); diff --git a/tests/integration/db/orgMembershipsManagement.test.ts b/tests/integration/db/orgMembershipsManagement.test.ts index 2bb3d67d7..34adea2ce 100644 --- a/tests/integration/db/orgMembershipsManagement.test.ts +++ b/tests/integration/db/orgMembershipsManagement.test.ts @@ -79,9 +79,21 @@ describe('multi-org membership management (integration)', () => { expect(members).toEqual( expect.arrayContaining([ - expect.objectContaining({ id: local.id, email: 'local@example.com', role: 'member' }), - // Per-org role (admin) wins over the guest's global role (member). - expect.objectContaining({ id: guest.id, email: 'guest@example.com', role: 'admin' }), + expect.objectContaining({ + id: local.id, + email: 'local@example.com', + role: 'member', + globalRole: 'member', + }), + // Per-org role (admin) wins over the guest's global role (member), + // but the GLOBAL role is also surfaced so the editor keeps targeting + // users.role (PR #1441 review). + expect.objectContaining({ + id: guest.id, + email: 'guest@example.com', + role: 'admin', + globalRole: 'member', + }), ]), ); expect(members).toHaveLength(2); diff --git a/tests/unit/api/routers/users.test.ts b/tests/unit/api/routers/users.test.ts index 639c5823e..da226c439 100644 --- a/tests/unit/api/routers/users.test.ts +++ b/tests/unit/api/routers/users.test.ts @@ -79,6 +79,7 @@ describe('usersRouter', () => { email: 'alice@example.com', name: 'Alice', role: 'admin', + globalRole: 'admin', createdAt: null, updatedAt: null, }, @@ -88,6 +89,7 @@ describe('usersRouter', () => { email: 'bob@example.com', name: 'Bob', role: 'member', + globalRole: 'member', createdAt: null, updatedAt: null, }, @@ -112,6 +114,7 @@ describe('usersRouter', () => { email: 'alice@example.com', name: 'Alice', role: 'admin', + globalRole: 'admin', createdAt: null, updatedAt: null, }, @@ -121,6 +124,7 @@ describe('usersRouter', () => { email: 'super@example.com', name: 'Super', role: 'admin', + globalRole: 'superadmin', createdAt: null, updatedAt: null, }, diff --git a/tests/unit/db/repositories/usersRepository.test.ts b/tests/unit/db/repositories/usersRepository.test.ts index 3b10d574a..ec15e62bb 100644 --- a/tests/unit/db/repositories/usersRepository.test.ts +++ b/tests/unit/db/repositories/usersRepository.test.ts @@ -40,7 +40,6 @@ import { getSessionByToken, getUserByEmail, getUserById, - listOrgUsers, setSessionActiveOrg, updateUser, } from '../../../../src/db/repositories/usersRepository.js'; @@ -173,57 +172,6 @@ describe('usersRepository', () => { }); }); - describe('listOrgUsers', () => { - it('returns all users for org without passwordHash', async () => { - const mockUsers = [ - { - id: 'u1', - orgId: 'org-1', - email: 'alice@example.com', - name: 'Alice', - role: 'admin', - createdAt: new Date('2024-01-01'), - updatedAt: new Date('2024-01-01'), - }, - { - id: 'u2', - orgId: 'org-1', - email: 'bob@example.com', - name: 'Bob', - role: 'member', - createdAt: new Date('2024-02-01'), - updatedAt: new Date('2024-02-01'), - }, - ]; - mockDb.chain.where.mockResolvedValueOnce(mockUsers); - - const result = await listOrgUsers('org-1'); - - expect(result).toHaveLength(2); - expect(result[0]).toEqual(mockUsers[0]); - expect(result[1]).toEqual(mockUsers[1]); - // Verify passwordHash is not in the result - for (const user of result) { - expect(user).not.toHaveProperty('passwordHash'); - } - }); - - it('returns empty array when no users in org', async () => { - mockDb.chain.where.mockResolvedValueOnce([]); - - const result = await listOrgUsers('empty-org'); - expect(result).toEqual([]); - }); - - it('queries by orgId', async () => { - mockDb.chain.where.mockResolvedValueOnce([]); - - await listOrgUsers('org-123'); - - expect(mockDb.db.select).toHaveBeenCalledTimes(1); - }); - }); - describe('createUserWithMembership', () => { /** * createUserWithMembership runs both inserts inside db.transaction(). Mock diff --git a/tools/create-admin-user.ts b/tools/create-admin-user.ts index f08a81dd3..66203a5a2 100644 --- a/tools/create-admin-user.ts +++ b/tools/create-admin-user.ts @@ -14,7 +14,7 @@ import bcrypt from 'bcrypt'; import { closeDb, getDb } from '../src/db/client.js'; -import { users } from '../src/db/schema/index.js'; +import { orgMemberships, users } from '../src/db/schema/index.js'; function parseArgs(argv: string[]): { email: string; password: string; name: string } { let email = ''; @@ -50,7 +50,7 @@ async function main(): Promise { const db = getDb(); const passwordHash = await bcrypt.hash(password, 10); - await db + const [user] = await db .insert(users) .values({ orgId: 'default', @@ -62,6 +62,20 @@ async function main(): Promise { .onConflictDoUpdate({ target: users.email, set: { passwordHash, name, role: 'superadmin' }, + }) + .returning({ id: users.id }); + + // Mirror a home-org membership so the bootstrap superadmin appears in its own + // org's membership-based listing (spec 021 plan 3 — `users.list` inner-joins + // org_memberships). 'superadmin' is a GLOBAL role; membership roles are + // per-org, so it maps to an 'admin' membership (mirroring migration 0053). + // Idempotent: re-running keeps a single membership row per (user, org). + await db + .insert(orgMemberships) + .values({ userId: user.id, orgId: 'default', role: 'admin' }) + .onConflictDoUpdate({ + target: [orgMemberships.userId, orgMemberships.orgId], + set: { role: 'admin', updatedAt: new Date() }, }); const port = process.env.DASHBOARD_PORT || process.env.PORT || '3001'; diff --git a/web/src/components/settings/user-form-dialog.tsx b/web/src/components/settings/user-form-dialog.tsx index 5562e8f02..bfd1c5c0c 100644 --- a/web/src/components/settings/user-form-dialog.tsx +++ b/web/src/components/settings/user-form-dialog.tsx @@ -9,7 +9,14 @@ interface User { id: string; name: string; email: string; + /** Per-org membership role (spec 021 plan 3). */ role: string; + /** + * Global account role (`users.role`). The editor pre-fills + writes this + * column because `users.update` targets the global role; pre-filling the + * per-org `role` instead would silently revert it on save (PR #1441 review). + */ + globalRole?: string; } interface UserFormDialogProps { @@ -26,7 +33,7 @@ export function UserFormDialog({ open, onOpenChange, user }: UserFormDialogProps const [email, setEmail] = useState(user?.email ?? ''); const [password, setPassword] = useState(''); const [role, setRole] = useState<'member' | 'admin'>( - (user?.role as 'member' | 'admin') ?? 'member', + (user?.globalRole as 'member' | 'admin') ?? 'member', ); const invalidate = () => { diff --git a/web/src/components/settings/users-table.tsx b/web/src/components/settings/users-table.tsx index da96fbb13..899d02a99 100644 --- a/web/src/components/settings/users-table.tsx +++ b/web/src/components/settings/users-table.tsx @@ -28,7 +28,14 @@ interface User { orgId: string; name: string; email: string; + /** Per-org membership role (spec 021 plan 3). */ role: string; + /** + * Global account role (`users.role`). The role column + CLI guard + editor + * target this column because `users.update` still writes the global role; + * per-org role rendering lands with the plan-4 UI (PR #1441 review). + */ + globalRole: string; createdAt: string | null; updatedAt: string | null; } @@ -78,13 +85,13 @@ export function UsersTable({ users }: { users: User[] }) { {u.name} {u.email} - {u.role} + {u.globalRole} {u.createdAt ? new Date(u.createdAt).toLocaleDateString() : '—'} - {u.role === 'superadmin' ? ( + {u.globalRole === 'superadmin' ? ( Manage via CLI ) : (
From db7821295a947b95a2fabe21d8703832fe86e3c1 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Wed, 24 Jun 2026 19:27:47 +0000 Subject: [PATCH 06/18] fix(cli): users list renders global role, not per-org membership role The membership-based list (listOrgMembers) returns a per-org role that can only ever be member|admin, so the CLI table was mislabeling superadmins as admin. Render globalRole instead, matching the web UI's interim choice and restoring correct superadmin visibility (PR #1441 review). Co-Authored-By: Claude Opus 4.8 --- src/cli/dashboard/users/list.ts | 8 +++++- tests/unit/cli/dashboard/users/users.test.ts | 29 ++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/cli/dashboard/users/list.ts b/src/cli/dashboard/users/list.ts index 59612a858..be8a2c239 100644 --- a/src/cli/dashboard/users/list.ts +++ b/src/cli/dashboard/users/list.ts @@ -18,7 +18,13 @@ export default class UsersList extends DashboardCommand { { key: 'id', header: 'ID' }, { key: 'email', header: 'Email' }, { key: 'name', header: 'Name' }, - { key: 'role', header: 'Role' }, + // Render the GLOBAL account role (`users.role`), not the per-org + // membership `role` returned alongside it. The list is now + // membership-based (`listOrgMembers`), whose per-org `role` can only + // ever be 'member' | 'admin' — so printing it would mislabel + // superadmins as 'admin'. `globalRole` restores correct superadmin + // visibility and matches the web UI's interim choice (PR #1441 review). + { key: 'globalRole', header: 'Role' }, { key: 'createdAt', header: 'Created', format: formatDate }, ]; diff --git a/tests/unit/cli/dashboard/users/users.test.ts b/tests/unit/cli/dashboard/users/users.test.ts index 027ac2d04..6811636fd 100644 --- a/tests/unit/cli/dashboard/users/users.test.ts +++ b/tests/unit/cli/dashboard/users/users.test.ts @@ -100,6 +100,35 @@ describe('UsersList (list)', () => { const cmd = new UsersList([], oclifConfig as never); await expect(cmd.run()).resolves.toBeUndefined(); }); + + it('renders the GLOBAL role, not the per-org membership role (PR #1441 review)', async () => { + // `listOrgMembers` returns a per-org `role` (only ever member|admin) + // alongside the global `globalRole`. A superadmin must still show as + // 'superadmin' in the CLI table — printing the per-org `role` would + // mislabel them as 'admin'. + const client = makeClient(); + (client.users.list.query as ReturnType).mockResolvedValue([ + { + id: 'super-1', + email: 'super@example.com', + name: 'Super Admin', + role: 'admin', + globalRole: 'superadmin', + createdAt: '2024-01-01T00:00:00.000Z', + }, + ]); + mockCreateDashboardClient.mockReturnValue(client); + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + const cmd = new UsersList([], oclifConfig as never); + await cmd.run(); + const output = logSpy.mock.calls.map((c) => String(c[0])).join('\n'); + expect(output).toContain('superadmin'); + } finally { + logSpy.mockRestore(); + } + }); }); // --------------------------------------------------------------------------- From 774b6aec5b31547e03554ff2e8637b80de2b356c Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Thu, 25 Jun 2026 07:02:52 +0000 Subject: [PATCH 07/18] fix(users): sync home-org membership role on global-role edit (PR #1441 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SHOULD_FIX from review: home-org permissions are read from org_memberships.role (resolveActorRoleInOrg), not users.role. The `update` mutation only wrote users.role, so after this spec's universal home-org membership backfill, member↔admin edits via Settings → Users (and `cascade users update --role`) were silent no-ops — the global role flipped but the membership the home-org permission check reads stayed put, so a "promoted" admin still hit FORBIDDEN. updateUser now accepts an optional syncHomeOrgMembership; when the role is part of the update it upserts the target's home-org membership role in the SAME transaction as the users.role write (no drift). Membership roles are per-org ('member' | 'admin'), so a global 'superadmin' maps to an 'admin' membership, mirroring createUserWithMembership. The mutation passes the target's home org (targetUser.orgId); access control already restricts non-superadmins to same-org targets, so this is the target's home org in every allowed case. Tests: 4 real-DB integration cases (sync on role change, superadmin→admin membership, upsert when legacy account has no row, no-op when role unchanged); updated the router unit assertions to the new call shape + a dedicated member→admin promotion test. typecheck, lint, unit (58) and integration (11) green. Note: the related addExistingUserToOrg observation (granting an admin *membership* in a non-home org is inert because adminProcedure gates the global role) is a separate, deeper model reconciliation that lands with the plan-4 dashboard work (MNG-1674) — out of scope here. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01RyBTx5JozjbpUko5SRyz4X --- src/api/routers/users.ts | 7 +- src/db/repositories/usersRepository.ts | 26 ++++++ .../db/orgMembershipsManagement.test.ts | 89 +++++++++++++++++++ tests/unit/api/routers/users.test.ts | 74 +++++++++++++-- 4 files changed, 188 insertions(+), 8 deletions(-) diff --git a/src/api/routers/users.ts b/src/api/routers/users.ts index d20f2a4f7..fb861d287 100644 --- a/src/api/routers/users.ts +++ b/src/api/routers/users.ts @@ -306,7 +306,12 @@ export const usersRouter = router({ updates.passwordHash = await bcrypt.hash(input.password, 10); } - await updateUser(input.id, updates); + // Sync the target's home-org membership role with a global-role change so + // the edit actually takes effect — home-org permissions are read from + // org_memberships.role, not users.role (PR #1441 review SHOULD_FIX). + await updateUser(input.id, updates, { + syncHomeOrgMembership: { orgId: targetUser.orgId }, + }); // Invalidate all sessions for the target user when their password changes. // This prevents stale sessions from remaining valid after a password reset. diff --git a/src/db/repositories/usersRepository.ts b/src/db/repositories/usersRepository.ts index 67a924589..e48afb2ed 100644 --- a/src/db/repositories/usersRepository.ts +++ b/src/db/repositories/usersRepository.ts @@ -156,6 +156,15 @@ export async function createUserWithMembership(params: { /** * Sparse update for name, email, role, passwordHash. Sets updatedAt on every update. + * + * `opts.syncHomeOrgMembership` keeps the user's home-org membership role in lock + * step with a global-role change. Home-org permissions are read from + * `org_memberships.role` (`resolveActorRoleInOrg`), not `users.role`, so without + * this a member↔admin edit via Settings/CLI is a silent no-op for actual + * permissions (PR #1441 review). Only applied when `updates.role` is present; + * membership roles are per-org ('member' | 'admin'), so a global 'superadmin' + * maps to an 'admin' membership (mirrors `createUserWithMembership`). The user + * row and the membership upsert run in one transaction so they cannot drift. */ export async function updateUser( id: string, @@ -165,6 +174,7 @@ export async function updateUser( role?: string; passwordHash?: string; }, + opts?: { syncHomeOrgMembership?: { orgId: string } }, ): Promise { const db = getDb(); const setClause: Record = { updatedAt: new Date() }; @@ -173,6 +183,22 @@ export async function updateUser( if (updates.role !== undefined) setClause.role = updates.role; if (updates.passwordHash !== undefined) setClause.passwordHash = updates.passwordHash; + const homeOrgId = opts?.syncHomeOrgMembership?.orgId; + if (homeOrgId !== undefined && updates.role !== undefined) { + const membershipRole = updates.role === 'superadmin' ? 'admin' : updates.role; + await db.transaction(async (tx) => { + await tx.update(users).set(setClause).where(eq(users.id, id)); + await tx + .insert(orgMemberships) + .values({ userId: id, orgId: homeOrgId, role: membershipRole }) + .onConflictDoUpdate({ + target: [orgMemberships.userId, orgMemberships.orgId], + set: { role: membershipRole, updatedAt: new Date() }, + }); + }); + return; + } + await db.update(users).set(setClause).where(eq(users.id, id)); } diff --git a/tests/integration/db/orgMembershipsManagement.test.ts b/tests/integration/db/orgMembershipsManagement.test.ts index 34adea2ce..0ce049800 100644 --- a/tests/integration/db/orgMembershipsManagement.test.ts +++ b/tests/integration/db/orgMembershipsManagement.test.ts @@ -18,6 +18,7 @@ import { import { createUserWithMembership, getUserByEmail, + updateUser, } from '../../../src/db/repositories/usersRepository.js'; import { truncateAll } from '../helpers/db.js'; import { seedMembership, seedOrg, seedUser } from '../helpers/seed.js'; @@ -171,4 +172,92 @@ describe('multi-org membership management (integration)', () => { expect(await listOrgMembers('other-org')).toEqual([]); }); }); + + // PR #1441 review (SHOULD_FIX): home-org permissions are read from + // org_memberships.role (resolveActorRoleInOrg), so a global-role change must + // also sync the home-org membership or member↔admin edits are silent no-ops. + describe('updateUser home-org membership sync', () => { + it('syncs the home-org membership role when the global role changes', async () => { + const user = await seedUser({ + orgId: 'home-org', + email: 'promote@example.com', + role: 'member', + }); + await seedMembership({ userId: user.id, orgId: 'home-org', role: 'member' }); + + await updateUser( + user.id, + { role: 'admin' }, + { syncHomeOrgMembership: { orgId: 'home-org' } }, + ); + + // Global role updated AND the membership role tracked it. + expect((await getUserByEmail('promote@example.com'))?.role).toBe('admin'); + expect(await getOrgMembership(user.id, 'home-org')).toEqual({ + orgId: 'home-org', + role: 'admin', + }); + }); + + it('maps a superadmin role change to an admin home-org membership', async () => { + const user = await seedUser({ + orgId: 'home-org', + email: 'super@example.com', + role: 'member', + }); + await seedMembership({ userId: user.id, orgId: 'home-org', role: 'member' }); + + await updateUser( + user.id, + { role: 'superadmin' }, + { syncHomeOrgMembership: { orgId: 'home-org' } }, + ); + + // Membership roles are per-org ('member' | 'admin'); superadmin → admin. + expect(await getOrgMembership(user.id, 'home-org')).toEqual({ + orgId: 'home-org', + role: 'admin', + }); + }); + + it('upserts the home-org membership when the legacy account has none', async () => { + const user = await seedUser({ + orgId: 'home-org', + email: 'legacy@example.com', + role: 'member', + }); + // No membership row seeded (pre-backfill account). + + await updateUser( + user.id, + { role: 'admin' }, + { syncHomeOrgMembership: { orgId: 'home-org' } }, + ); + + expect(await getOrgMembership(user.id, 'home-org')).toEqual({ + orgId: 'home-org', + role: 'admin', + }); + }); + + it('leaves membership untouched when the update does not change the role', async () => { + const user = await seedUser({ + orgId: 'home-org', + email: 'rename@example.com', + role: 'admin', + }); + await seedMembership({ userId: user.id, orgId: 'home-org', role: 'admin' }); + + await updateUser( + user.id, + { name: 'Renamed' }, + { syncHomeOrgMembership: { orgId: 'home-org' } }, + ); + + expect(await getOrgMembership(user.id, 'home-org')).toEqual({ + orgId: 'home-org', + role: 'admin', + }); + }); + }); }); diff --git a/tests/unit/api/routers/users.test.ts b/tests/unit/api/routers/users.test.ts index da226c439..c2b8f70b4 100644 --- a/tests/unit/api/routers/users.test.ts +++ b/tests/unit/api/routers/users.test.ts @@ -466,7 +466,13 @@ describe('usersRouter', () => { await caller.update({ id: 'user-2', name: 'Updated Name' }); - expect(mockUpdateUser).toHaveBeenCalledWith('user-2', { name: 'Updated Name' }); + expect(mockUpdateUser).toHaveBeenCalledWith( + 'user-2', + { name: 'Updated Name' }, + { + syncHomeOrgMembership: { orgId: 'org-1' }, + }, + ); }); it('allows sparse update for email', async () => { @@ -476,7 +482,13 @@ describe('usersRouter', () => { await caller.update({ id: 'user-2', email: 'updated@example.com' }); - expect(mockUpdateUser).toHaveBeenCalledWith('user-2', { email: 'updated@example.com' }); + expect(mockUpdateUser).toHaveBeenCalledWith( + 'user-2', + { email: 'updated@example.com' }, + { + syncHomeOrgMembership: { orgId: 'org-1' }, + }, + ); }); it('hashes password when provided', async () => { @@ -487,7 +499,13 @@ describe('usersRouter', () => { await caller.update({ id: 'user-2', password: 'newpassword12' }); expect(mockBcryptHash).toHaveBeenCalledWith('newpassword12', 10); - expect(mockUpdateUser).toHaveBeenCalledWith('user-2', { passwordHash: 'hashed-password' }); + expect(mockUpdateUser).toHaveBeenCalledWith( + 'user-2', + { passwordHash: 'hashed-password' }, + { + syncHomeOrgMembership: { orgId: 'org-1' }, + }, + ); }); it('prevents self-demotion (cannot change own role)', async () => { @@ -543,7 +561,13 @@ describe('usersRouter', () => { await caller.update({ id: 'user-2', role: 'superadmin' }); - expect(mockUpdateUser).toHaveBeenCalledWith('user-2', { role: 'superadmin' }); + expect(mockUpdateUser).toHaveBeenCalledWith( + 'user-2', + { role: 'superadmin' }, + { + syncHomeOrgMembership: { orgId: 'org-1' }, + }, + ); }); it('prevents non-superadmin from editing ANY field on a superadmin user (name)', async () => { @@ -564,7 +588,13 @@ describe('usersRouter', () => { await caller.update({ id: 'user-super2', name: 'New Super Name' }); - expect(mockUpdateUser).toHaveBeenCalledWith('user-super2', { name: 'New Super Name' }); + expect(mockUpdateUser).toHaveBeenCalledWith( + 'user-super2', + { name: 'New Super Name' }, + { + syncHomeOrgMembership: { orgId: 'org-1' }, + }, + ); }); it('prevents non-superadmin from revoking superadmin role', async () => { @@ -585,7 +615,31 @@ describe('usersRouter', () => { await caller.update({ id: 'user-2', role: 'admin' }); - expect(mockUpdateUser).toHaveBeenCalledWith('user-2', { role: 'admin' }); + expect(mockUpdateUser).toHaveBeenCalledWith( + 'user-2', + { role: 'admin' }, + { + syncHomeOrgMembership: { orgId: 'org-1' }, + }, + ); + }); + + it('member→admin promotion syncs the target home-org membership (PR #1441 review)', async () => { + // The whole point of the fix: home-org permissions read org_memberships.role, + // so the promotion must reach the target's home org or it is a silent no-op. + mockGetUserById.mockResolvedValue({ id: 'user-2', orgId: 'org-1', role: 'member' }); + mockUpdateUser.mockResolvedValue(undefined); + const caller = createCaller({ user: mockAdminUser, effectiveOrgId: mockAdminUser.orgId }); + + await caller.update({ id: 'user-2', role: 'admin' }); + + expect(mockUpdateUser).toHaveBeenCalledWith( + 'user-2', + { role: 'admin' }, + { + syncHomeOrgMembership: { orgId: 'org-1' }, + }, + ); }); it('rejects update password shorter than 12 characters', async () => { @@ -796,7 +850,13 @@ describe('usersRouter', () => { await caller.update({ id: 'user-2', name: 'Renamed' }); - expect(mockUpdateUser).toHaveBeenCalledWith('user-2', { name: 'Renamed' }); + expect(mockUpdateUser).toHaveBeenCalledWith( + 'user-2', + { name: 'Renamed' }, + { + syncHomeOrgMembership: { orgId: 'org-2' }, + }, + ); expect(mockGetOrgMembership).not.toHaveBeenCalled(); }); }); From 291f6100633cf1af9619cb4d5dddc4af14570da7 Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Thu, 25 Jun 2026 07:34:22 +0000 Subject: [PATCH 08/18] feat(users): add "remove from this org" for guest members (PR #1441 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review flagged a footgun: now that the Users list is membership-based, a guest (home org elsewhere, granted via add-to-org) appears in the org's table, and the Delete button deletes the ENTIRE account — org_memberships cascades, dropping the account's memberships in every org, not just the one being viewed. Adds a proper "remove from this org" action that drops only the membership: - removeOrgMembership(userId, orgId) repo helper — deletes the single org_memberships row, returns { removed } (false when none existed). Account and all other memberships are untouched. - users.removeFromOrg mutation — org admins (and superadmins) can remove guests from THEIR org. Unlike delete/update it intentionally does not hide cross-home-org targets as NOT_FOUND (managing your own org's guest list is the point). Guards: no self-removal; only superadmins can act on a superadmin; refuses to remove a user from their HOME org (delete the account instead); NOT_FOUND when the target has no membership here. - listOrgMembers now returns homeOrgId + isGuest (computed against the listed org) so the UI can branch. Additive — existing consumers unaffected. - Web Users table: guests show a "Remove from org" action (UserMinus) with a dedicated dialog ("removes access to this org only; account and other orgs unaffected"); home-org members keep "Delete account" (dialog now says it deletes across every org). - CLI: `cascade users remove-from-org ` (mirrors users delete: positional id + -y confirm). Tests: 2 real-DB integration cases for removeOrgMembership + homeOrgId/isGuest on listOrgMembers; 7 router unit cases (guest removal, self-removal, NOT_FOUND account/membership, home-org refusal, superadmin-target guard, member caller); 4 CLI unit cases. typecheck, lint, web build, unit-api (1737) and CLI (651) green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01RyBTx5JozjbpUko5SRyz4X --- src/api/routers/users.ts | 60 +++++++++++++++ src/cli/dashboard/users/remove-from-org.ts | 43 +++++++++++ .../repositories/orgMembershipsRepository.ts | 33 +++++++- .../db/orgMembershipsManagement.test.ts | 42 ++++++++++ tests/unit/api/routers/users.test.ts | 77 +++++++++++++++++++ tests/unit/cli/dashboard/users/users.test.ts | 52 +++++++++++++ .../orgMembershipsRepository.test.ts | 11 ++- web/src/components/settings/users-table.tsx | 76 +++++++++++++++--- 8 files changed, 381 insertions(+), 13 deletions(-) create mode 100644 src/cli/dashboard/users/remove-from-org.ts diff --git a/src/api/routers/users.ts b/src/api/routers/users.ts index fb861d287..b88a523c9 100644 --- a/src/api/routers/users.ts +++ b/src/api/routers/users.ts @@ -5,6 +5,7 @@ import { addOrgMembership, getOrgMembership, listOrgMembers, + removeOrgMembership, } from '../../db/repositories/orgMembershipsRepository.js'; import { createUserWithMembership, @@ -345,4 +346,63 @@ export const usersRouter = router({ await deleteUser(input.id); }), + + /** + * Remove a user's membership in the effective org WITHOUT deleting the account + * (spec 021 plan 3). This is the "remove from this org" action for guests — + * accounts whose home org is elsewhere, surfaced in the list via `isGuest`. + * Distinct from `delete`, which removes the whole account and cascades its + * memberships across every org (PR #1441 review: that was a footgun when a + * guest's Delete button was used). + * + * - Org admins (and superadmins) may remove guests from THEIR org; unlike + * `delete`/`update`, this intentionally does NOT hide cross-home-org targets + * as NOT_FOUND, because managing your own org's guest list is the point. + * - Only superadmins can act on a superadmin account. + * - Refuses to remove a user from their HOME org (that would orphan the + * account — delete it instead). + */ + removeFromOrg: adminProcedure + .input(z.object({ userId: z.string() })) + .mutation(async ({ ctx, input }) => { + const actorRole = await resolveActorRole(ctx); + assertOrgAdmin(actorRole); + + if (ctx.user.id === input.userId) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Cannot remove yourself from the organization', + }); + } + + const targetUser = await getUserById(input.userId); + if (!targetUser) { + throw new TRPCError({ code: 'NOT_FOUND' }); + } + + if (targetUser.role === 'superadmin' && actorRole !== 'superadmin') { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Only superadmins can remove superadmin users', + }); + } + + if (targetUser.orgId === ctx.effectiveOrgId) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: + 'This is the user’s home organization. Delete the account instead of removing the membership.', + }); + } + + const { removed } = await removeOrgMembership(input.userId, ctx.effectiveOrgId); + if (!removed) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'This user is not a member of this organization.', + }); + } + + return { userId: input.userId, orgId: ctx.effectiveOrgId, removed: true }; + }), }); diff --git a/src/cli/dashboard/users/remove-from-org.ts b/src/cli/dashboard/users/remove-from-org.ts new file mode 100644 index 000000000..7d3a5586d --- /dev/null +++ b/src/cli/dashboard/users/remove-from-org.ts @@ -0,0 +1,43 @@ +import { Args, Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; +import { confirm } from '../_shared/confirm.js'; + +export default class UsersRemoveFromOrg extends DashboardCommand { + static override description = + 'Remove a user from the current organization (drops only the membership; the account and other organizations are unaffected).'; + + static override examples = ['<%= config.bin %> users remove-from-org 0a1b2c3d-...']; + + static override args = { + id: Args.string({ + description: 'User ID (UUID) to remove from this organization', + required: true, + }), + }; + + static override flags = { + ...DashboardCommand.baseFlags, + yes: Flags.boolean({ description: 'Skip confirmation', char: 'y', default: false }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(UsersRemoveFromOrg); + + await confirm(`Remove user ${args.id} from this organization?`, flags.yes); + + try { + const result = await this.withSpinner('Removing user from organization...', () => + this.client.users.removeFromOrg.mutate({ userId: args.id }), + ); + + if (flags.json) { + this.outputJson(result); + return; + } + + this.success(`Removed user ${args.id} from this organization`); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/db/repositories/orgMembershipsRepository.ts b/src/db/repositories/orgMembershipsRepository.ts index 91db54a44..ecfd58a82 100644 --- a/src/db/repositories/orgMembershipsRepository.ts +++ b/src/db/repositories/orgMembershipsRepository.ts @@ -79,6 +79,14 @@ export interface OrgMember { role: string; /** Global account role from `users.role` ('member' | 'admin' | 'superadmin'). */ globalRole: string; + /** The account's HOME org (`users.org_id`), which may differ from the listed org. */ + homeOrgId: string; + /** + * True when the account's home org is NOT the listed org — i.e. a "guest" + * granted membership via `addExistingUserToOrg`. Drives the "remove from this + * org" UX (remove the membership) vs. "delete account" for home-org members. + */ + isGuest: boolean; createdAt: Date | null; updatedAt: Date | null; } @@ -102,7 +110,7 @@ export async function listOrgMembers( if (opts?.excludeGlobalRole !== undefined) { conditions.push(ne(users.role, opts.excludeGlobalRole)); } - return db + const rows = await db .select({ id: users.id, orgId: orgMemberships.orgId, @@ -110,12 +118,35 @@ export async function listOrgMembers( name: users.name, role: orgMemberships.role, globalRole: users.role, + homeOrgId: users.orgId, createdAt: users.createdAt, updatedAt: users.updatedAt, }) .from(orgMemberships) .innerJoin(users, eq(orgMemberships.userId, users.id)) .where(and(...conditions)); + + // A member whose HOME org differs from the listed org is a guest — surfaced + // so the UI offers "remove from this org" instead of whole-account deletion. + return rows.map((row) => ({ ...row, isGuest: row.homeOrgId !== orgId })); +} + +/** + * Remove a user's membership in a single org WITHOUT touching the account + * (spec 021 plan 3). This is the "remove from this org" action — distinct from + * deleting the whole account (`deleteUser`), which cascades across every org. + * Returns `{ removed: false }` when there was no membership row to delete. + */ +export async function removeOrgMembership( + userId: string, + orgId: string, +): Promise<{ removed: boolean }> { + const db = getDb(); + const deleted = await db + .delete(orgMemberships) + .where(and(eq(orgMemberships.userId, userId), eq(orgMemberships.orgId, orgId))) + .returning({ userId: orgMemberships.userId }); + return { removed: deleted.length > 0 }; } /** diff --git a/tests/integration/db/orgMembershipsManagement.test.ts b/tests/integration/db/orgMembershipsManagement.test.ts index 0ce049800..f0a716f7c 100644 --- a/tests/integration/db/orgMembershipsManagement.test.ts +++ b/tests/integration/db/orgMembershipsManagement.test.ts @@ -14,6 +14,7 @@ import { addOrgMembership, getOrgMembership, listOrgMembers, + removeOrgMembership, } from '../../../src/db/repositories/orgMembershipsRepository.js'; import { createUserWithMembership, @@ -85,6 +86,9 @@ describe('multi-org membership management (integration)', () => { email: 'local@example.com', role: 'member', globalRole: 'member', + // Home org == listed org → not a guest. + homeOrgId: 'other-org', + isGuest: false, }), // Per-org role (admin) wins over the guest's global role (member), // but the GLOBAL role is also surfaced so the editor keeps targeting @@ -94,6 +98,9 @@ describe('multi-org membership management (integration)', () => { email: 'guest@example.com', role: 'admin', globalRole: 'member', + // Home org elsewhere → guest; drives "remove from this org" UX. + homeOrgId: 'home-org', + isGuest: true, }), ]), ); @@ -173,6 +180,41 @@ describe('multi-org membership management (integration)', () => { }); }); + describe('removeOrgMembership', () => { + it('removes only the membership in the given org, leaving the account and other memberships', async () => { + // A guest whose home org is home-org, also a member of other-org. + const guest = await seedUser({ + orgId: 'home-org', + email: 'guest@example.com', + role: 'member', + }); + await seedMembership({ userId: guest.id, orgId: 'home-org', role: 'member' }); + await seedMembership({ userId: guest.id, orgId: 'other-org', role: 'admin' }); + + const result = await removeOrgMembership(guest.id, 'other-org'); + + expect(result).toEqual({ removed: true }); + // Gone from other-org… + expect(await getOrgMembership(guest.id, 'other-org')).toBeNull(); + // …but the account and its home-org membership survive. + expect((await getUserByEmail('guest@example.com'))?.id).toBe(guest.id); + expect(await getOrgMembership(guest.id, 'home-org')).toEqual({ + orgId: 'home-org', + role: 'member', + }); + }); + + it('reports removed:false when there was no membership to remove', async () => { + const user = await seedUser({ + orgId: 'home-org', + email: 'nomember@example.com', + role: 'member', + }); + + expect(await removeOrgMembership(user.id, 'other-org')).toEqual({ removed: false }); + }); + }); + // PR #1441 review (SHOULD_FIX): home-org permissions are read from // org_memberships.role (resolveActorRoleInOrg), so a global-role change must // also sync the home-org membership or member↔admin edits are silent no-ops. diff --git a/tests/unit/api/routers/users.test.ts b/tests/unit/api/routers/users.test.ts index c2b8f70b4..a842335d7 100644 --- a/tests/unit/api/routers/users.test.ts +++ b/tests/unit/api/routers/users.test.ts @@ -13,6 +13,7 @@ const { mockBcryptHash, mockGetOrgMembership, mockAddOrgMembership, + mockRemoveOrgMembership, } = vi.hoisted(() => ({ mockListOrgMembers: vi.fn(), mockCreateUserWithMembership: vi.fn(), @@ -24,6 +25,7 @@ const { mockBcryptHash: vi.fn(), mockGetOrgMembership: vi.fn(), mockAddOrgMembership: vi.fn(), + mockRemoveOrgMembership: vi.fn(), })); vi.mock('../../../../src/db/repositories/usersRepository.js', () => ({ @@ -44,6 +46,7 @@ vi.mock('../../../../src/db/repositories/orgMembershipsRepository.js', () => ({ getOrgMembership: mockGetOrgMembership, listOrgMembers: mockListOrgMembers, addOrgMembership: mockAddOrgMembership, + removeOrgMembership: mockRemoveOrgMembership, })); vi.mock('bcrypt', () => ({ @@ -65,6 +68,7 @@ describe('usersRouter', () => { mockBcryptHash.mockResolvedValue('hashed-password'); mockDeleteUserSessions.mockResolvedValue(undefined); mockAddOrgMembership.mockResolvedValue(undefined); + mockRemoveOrgMembership.mockResolvedValue({ removed: true }); // Default: caller has no explicit membership row, so the per-org role // resolver falls back to the global role for their home org. mockGetOrgMembership.mockResolvedValue(null); @@ -783,6 +787,79 @@ describe('usersRouter', () => { }); }); + // "Remove from this org" — drop only the membership, never the account + // (PR #1441 review: whole-account delete of a guest was a footgun). + describe('removeFromOrg', () => { + it('removes a guest membership in the effective org without deleting the account', async () => { + mockGetUserById.mockResolvedValue({ id: 'guest-1', orgId: 'org-2', role: 'member' }); + const caller = createCaller({ user: mockAdminUser, effectiveOrgId: mockAdminUser.orgId }); + + const result = await caller.removeFromOrg({ userId: 'guest-1' }); + + expect(mockRemoveOrgMembership).toHaveBeenCalledWith('guest-1', 'org-1'); + expect(mockDeleteUser).not.toHaveBeenCalled(); + expect(result).toEqual({ userId: 'guest-1', orgId: 'org-1', removed: true }); + }); + + it('prevents removing yourself from the org', async () => { + const caller = createCaller({ user: mockAdminUser, effectiveOrgId: mockAdminUser.orgId }); + + await expect(caller.removeFromOrg({ userId: 'user-1' })).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + expect(mockRemoveOrgMembership).not.toHaveBeenCalled(); + }); + + it('throws NOT_FOUND when the target account does not exist', async () => { + mockGetUserById.mockResolvedValue(null); + const caller = createCaller({ user: mockAdminUser, effectiveOrgId: mockAdminUser.orgId }); + + await expect(caller.removeFromOrg({ userId: 'ghost' })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + expect(mockRemoveOrgMembership).not.toHaveBeenCalled(); + }); + + it('refuses to remove a user from their HOME org (delete the account instead)', async () => { + // Home org == effective org → not a guest. + mockGetUserById.mockResolvedValue({ id: 'home-1', orgId: 'org-1', role: 'member' }); + const caller = createCaller({ user: mockAdminUser, effectiveOrgId: mockAdminUser.orgId }); + + await expect(caller.removeFromOrg({ userId: 'home-1' })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + expect(mockRemoveOrgMembership).not.toHaveBeenCalled(); + }); + + it('throws NOT_FOUND when the target has no membership in this org', async () => { + mockGetUserById.mockResolvedValue({ id: 'guest-1', orgId: 'org-2', role: 'member' }); + mockRemoveOrgMembership.mockResolvedValue({ removed: false }); + const caller = createCaller({ user: mockAdminUser, effectiveOrgId: mockAdminUser.orgId }); + + await expect(caller.removeFromOrg({ userId: 'guest-1' })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + + it('prevents a non-superadmin from removing a superadmin account', async () => { + mockGetUserById.mockResolvedValue({ id: 'super-guest', orgId: 'org-2', role: 'superadmin' }); + const caller = createCaller({ user: mockAdminUser, effectiveOrgId: mockAdminUser.orgId }); + + await expect(caller.removeFromOrg({ userId: 'super-guest' })).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + expect(mockRemoveOrgMembership).not.toHaveBeenCalled(); + }); + + it('rejects a member caller (adminProcedure)', async () => { + const caller = createCaller({ user: mockMember, effectiveOrgId: mockMember.orgId }); + + await expect(caller.removeFromOrg({ userId: 'guest-1' })).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + }); + }); + // ===================================================================== // Per-org role governs user management (spec 021 plan 2) // AC #4: per-org role governs permissions diff --git a/tests/unit/cli/dashboard/users/users.test.ts b/tests/unit/cli/dashboard/users/users.test.ts index 6811636fd..7b153f62e 100644 --- a/tests/unit/cli/dashboard/users/users.test.ts +++ b/tests/unit/cli/dashboard/users/users.test.ts @@ -26,6 +26,7 @@ import UsersAddToOrg from '../../../../../src/cli/dashboard/users/add-to-org.js' import UsersCreate from '../../../../../src/cli/dashboard/users/create.js'; import UsersDelete from '../../../../../src/cli/dashboard/users/delete.js'; import UsersList from '../../../../../src/cli/dashboard/users/list.js'; +import UsersRemoveFromOrg from '../../../../../src/cli/dashboard/users/remove-from-org.js'; import UsersUpdate from '../../../../../src/cli/dashboard/users/update.js'; // oclif's Command.parse() calls this.config.runHook internally @@ -57,6 +58,13 @@ function makeClient(overrides: Record = {}) { alreadyMember: false, }), }, + removeFromOrg: { + mutate: vi.fn().mockResolvedValue({ + userId: 'user-uuid-123', + orgId: 'org-1', + removed: true, + }), + }, }, ...overrides, }; @@ -413,3 +421,47 @@ describe('UsersAddToOrg (add-to-org)', () => { await expect(cmd.run()).rejects.toThrow(); }); }); + +// --------------------------------------------------------------------------- +// users remove-from-org (spec 021 plan 3 — "remove from this org") +// --------------------------------------------------------------------------- +describe('UsersRemoveFromOrg (remove-from-org)', () => { + beforeEach(() => { + mockLoadConfig.mockReturnValue(baseConfig); + }); + + it('passes the user ID with --yes to removeFromOrg.mutate', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new UsersRemoveFromOrg(['user-uuid-123', '--yes'], oclifConfig as never); + await cmd.run(); + + expect(client.users.removeFromOrg.mutate).toHaveBeenCalledWith({ userId: 'user-uuid-123' }); + }); + + it('auto-accepts without --yes in non-TTY environments', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new UsersRemoveFromOrg(['user-uuid-123'], oclifConfig as never); + await expect(cmd.run()).resolves.toBeUndefined(); + expect(client.users.removeFromOrg.mutate).toHaveBeenCalledWith({ userId: 'user-uuid-123' }); + }); + + it('outputs json when --json flag is set', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new UsersRemoveFromOrg(['user-uuid-123', '--json'], oclifConfig as never); + await expect(cmd.run()).resolves.toBeUndefined(); + expect(client.users.removeFromOrg.mutate).toHaveBeenCalled(); + }); + + it('requires the user ID argument', async () => { + mockCreateDashboardClient.mockReturnValue(makeClient()); + + const cmd = new UsersRemoveFromOrg(['--yes'], oclifConfig as never); + await expect(cmd.run()).rejects.toThrow(); + }); +}); diff --git a/tests/unit/db/repositories/orgMembershipsRepository.test.ts b/tests/unit/db/repositories/orgMembershipsRepository.test.ts index 079394120..14f619577 100644 --- a/tests/unit/db/repositories/orgMembershipsRepository.test.ts +++ b/tests/unit/db/repositories/orgMembershipsRepository.test.ts @@ -78,7 +78,7 @@ describe('orgMembershipsRepository', () => { }); describe('listOrgMembers', () => { - it('returns the org membership joined with each account, using the per-org role', async () => { + it('returns the org membership with per-org role + isGuest (home org != listed org)', async () => { const rows = [ { id: 'user-1', @@ -86,6 +86,8 @@ describe('orgMembershipsRepository', () => { email: 'alice@example.com', name: 'Alice', role: 'admin', + globalRole: 'admin', + homeOrgId: 'org-2', // home org == listed org createdAt: null, updatedAt: null, }, @@ -95,6 +97,8 @@ describe('orgMembershipsRepository', () => { email: 'bob@example.com', name: 'Bob', role: 'member', + globalRole: 'member', + homeOrgId: 'org-9', // home org elsewhere → guest createdAt: null, updatedAt: null, }, @@ -102,7 +106,10 @@ describe('orgMembershipsRepository', () => { mockDb.chain.where.mockResolvedValueOnce(rows); const result = await listOrgMembers('org-2'); - expect(result).toEqual(rows); + expect(result).toEqual([ + { ...rows[0], isGuest: false }, + { ...rows[1], isGuest: true }, + ]); expect(mockDb.chain.innerJoin).toHaveBeenCalled(); }); diff --git a/web/src/components/settings/users-table.tsx b/web/src/components/settings/users-table.tsx index 899d02a99..457443903 100644 --- a/web/src/components/settings/users-table.tsx +++ b/web/src/components/settings/users-table.tsx @@ -1,5 +1,5 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { Pencil, Trash2 } from 'lucide-react'; +import { Pencil, Trash2, UserMinus } from 'lucide-react'; import { useState } from 'react'; import { AlertDialog, @@ -36,6 +36,12 @@ interface User { * per-org role rendering lands with the plan-4 UI (PR #1441 review). */ globalRole: string; + /** + * True when the account's home org is elsewhere — a "guest" granted membership + * via add-to-org. Guests get "Remove from this org" (drops only the membership) + * instead of "Delete account" (removes the whole account across every org). + */ + isGuest?: boolean; createdAt: string | null; updatedAt: string | null; } @@ -49,16 +55,28 @@ function roleVariant(role: string): 'default' | 'secondary' | 'destructive' | 'o export function UsersTable({ users }: { users: User[] }) { const queryClient = useQueryClient(); const [deleteId, setDeleteId] = useState(null); + const [removeFromOrgId, setRemoveFromOrgId] = useState(null); const [editUser, setEditUser] = useState(null); + const invalidateList = () => + queryClient.invalidateQueries({ queryKey: trpc.users.list.queryOptions().queryKey }); + const deleteMutation = useMutation({ mutationFn: (id: string) => trpcClient.users.delete.mutate({ id }), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: trpc.users.list.queryOptions().queryKey }); + invalidateList(); setDeleteId(null); }, }); + const removeFromOrgMutation = useMutation({ + mutationFn: (userId: string) => trpcClient.users.removeFromOrg.mutate({ userId }), + onSuccess: () => { + invalidateList(); + setRemoveFromOrgId(null); + }, + }); + return ( <>
@@ -99,16 +117,29 @@ export function UsersTable({ users }: { users: User[] }) { type="button" onClick={() => setEditUser(u)} className="p-1 text-muted-foreground hover:text-foreground" + title="Edit user" > - + {u.isGuest ? ( + + ) : ( + + )}
)} @@ -123,7 +154,8 @@ export function UsersTable({ users }: { users: User[] }) { Delete User - This will permanently delete this user account. This action cannot be undone. + This will permanently delete this user account across every organization. This action + cannot be undone. @@ -138,6 +170,30 @@ export function UsersTable({ users }: { users: User[] }) { + !open && setRemoveFromOrgId(null)} + > + + + Remove from organization + + This removes the user’s access to this organization only. Their account and membership + in other organizations are unaffected. + + + + Cancel + removeFromOrgId && removeFromOrgMutation.mutate(removeFromOrgId)} + variant="destructive" + > + Remove + + + + + {editUser && ( Date: Thu, 25 Jun 2026 08:12:18 +0000 Subject: [PATCH 09/18] =?UTF-8?q?feat(web):=20multi-org=20membership=20UI?= =?UTF-8?q?=20=E2=80=94=20org=20switcher,=20add-to-org=20form,=20member=20?= =?UTF-8?q?list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec 021 plan 4 (MNG-1674): the user-facing layer over the curl-testable backend from plans 2-3 (web/ only). - Active-org switcher (sidebar) for non-superadmin multi-org members, backed by auth.listMyOrgs + auth.setActiveOrg; invalidates every query on switch so the dashboard refetches against the new active org. Single-org users get an inert org-name banner (spec AC #9). Superadmin cross-org switching via x-org-context is unchanged (spec AC #7). - Add-existing-account dialog on Settings → Users calling users.addExistingUserToOrg, surfacing the NOT_FOUND envelope inline; the complementary CONFLICT from users.create is already shown inline by the create dialog (spec AC #1). - Member list now renders per-org role alongside the account role plus a Guest badge for cross-home members (spec AC #5). Pure helpers (shouldShowOrgSwitcher, resolveActiveOrgName, formatAddToOrgSuccess, describeMemberRow) and SSR-safe presentational components (OrgSwitcherView, AddToOrgForm) are unit-tested; hook-wired containers are thin. Co-Authored-By: Claude Opus 4.8 --- tests/unit/web/add-to-org-dialog.test.ts | 85 ++++++++ tests/unit/web/org-switcher.test.ts | 126 ++++++++++++ tests/unit/web/users-table-membership.test.ts | 43 ++++ web/src/components/layout/org-switcher.tsx | 149 ++++++++++++++ web/src/components/layout/sidebar.tsx | 49 +++-- .../components/settings/add-to-org-dialog.tsx | 193 ++++++++++++++++++ web/src/components/settings/users-table.tsx | 145 ++++++++----- web/src/routes/settings/users.tsx | 28 ++- 8 files changed, 742 insertions(+), 76 deletions(-) create mode 100644 tests/unit/web/add-to-org-dialog.test.ts create mode 100644 tests/unit/web/org-switcher.test.ts create mode 100644 tests/unit/web/users-table-membership.test.ts create mode 100644 web/src/components/layout/org-switcher.tsx create mode 100644 web/src/components/settings/add-to-org-dialog.tsx diff --git a/tests/unit/web/add-to-org-dialog.test.ts b/tests/unit/web/add-to-org-dialog.test.ts new file mode 100644 index 000000000..c6b23fa38 --- /dev/null +++ b/tests/unit/web/add-to-org-dialog.test.ts @@ -0,0 +1,85 @@ +/** + * Tests for the "add existing account to this org" form (spec 021 plan 4, AC #1). + * + * Covers the success-message helper and the presentational `AddToOrgForm`, which + * is pure + SSR-safe so it renders under `renderToStaticMarkup`. The hook-wired + * container (`AddToOrgDialog`) is not rendered here. + */ + +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import { + AddToOrgForm, + formatAddToOrgSuccess, +} from '../../../web/src/components/settings/add-to-org-dialog.js'; + +describe('formatAddToOrgSuccess', () => { + it('describes a fresh grant', () => { + expect( + formatAddToOrgSuccess({ email: 'jane@example.com', role: 'member', alreadyMember: false }), + ).toBe('Added jane@example.com to this organization as member.'); + }); + + it('describes an idempotent re-grant as a role update', () => { + expect( + formatAddToOrgSuccess({ email: 'jane@example.com', role: 'admin', alreadyMember: true }), + ).toBe("Updated jane@example.com's role to admin in this organization."); + }); +}); + +const baseProps = { + email: '', + role: 'member' as const, + onEmailChange: () => {}, + onRoleChange: () => {}, + onSubmit: () => {}, + onCancel: () => {}, +}; + +describe('AddToOrgForm', () => { + it('renders the email field and both role options', () => { + const html = renderToStaticMarkup(createElement(AddToOrgForm, baseProps)); + expect(html).toContain('data-form="add-to-org"'); + expect(html).toContain('id="add-to-org-email"'); + expect(html).toContain('type="email"'); + expect(html).toContain('value="member"'); + expect(html).toContain('value="admin"'); + }); + + it('reflects the current email value', () => { + const html = renderToStaticMarkup( + createElement(AddToOrgForm, { ...baseProps, email: 'jane@example.com' }), + ); + expect(html).toContain('value="jane@example.com"'); + }); + + it('surfaces the NOT_FOUND envelope inline', () => { + const html = renderToStaticMarkup( + createElement(AddToOrgForm, { + ...baseProps, + errorMessage: + 'No account exists with this email. Create the user first with `cascade users create`.', + }), + ); + expect(html).toContain('data-message="error"'); + expect(html).toContain('No account exists with this email'); + }); + + it('shows the success banner when a grant succeeds', () => { + const html = renderToStaticMarkup( + createElement(AddToOrgForm, { + ...baseProps, + successMessage: 'Added jane@example.com to this organization as member.', + }), + ); + expect(html).toContain('data-message="success"'); + expect(html).toContain('Added jane@example.com to this organization as member.'); + }); + + it('disables the submit button and shows progress while pending', () => { + const html = renderToStaticMarkup(createElement(AddToOrgForm, { ...baseProps, pending: true })); + expect(html).toContain('disabled'); + expect(html).toContain('Adding...'); + }); +}); diff --git a/tests/unit/web/org-switcher.test.ts b/tests/unit/web/org-switcher.test.ts new file mode 100644 index 000000000..646af1b73 --- /dev/null +++ b/tests/unit/web/org-switcher.test.ts @@ -0,0 +1,126 @@ +/** + * Tests for the membership-based active-org switcher (spec 021 plan 4). + * + * Covers the pure decision helpers plus the presentational `OrgSwitcherView`, + * which is built from SSR-safe primitives (`NativeSelect`) so it renders under + * `renderToStaticMarkup` in the node test environment. The hook-wired container + * (`OrgSwitcher`) is intentionally not rendered here. + */ + +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; +import { + type MyOrg, + OrgSwitcherView, + resolveActiveOrgName, + shouldShowOrgSwitcher, +} from '../../../web/src/components/layout/org-switcher.js'; + +const acme: MyOrg = { id: 'org-a', name: 'Acme', role: 'admin' }; +const beta: MyOrg = { id: 'org-b', name: 'Beta', role: 'member' }; + +describe('shouldShowOrgSwitcher', () => { + it('hides the switcher for zero or one membership (inert, spec AC #9)', () => { + expect(shouldShowOrgSwitcher([])).toBe(false); + expect(shouldShowOrgSwitcher([acme])).toBe(false); + }); + + it('shows the switcher once the user belongs to more than one org', () => { + expect(shouldShowOrgSwitcher([acme, beta])).toBe(true); + }); +}); + +describe('resolveActiveOrgName', () => { + it('prefers the org matched by the active id', () => { + expect(resolveActiveOrgName([acme, beta], 'org-b', 'fallback')).toBe('Beta'); + }); + + it('falls back to the sole membership when nothing matches the active id', () => { + expect(resolveActiveOrgName([acme], 'org-x', 'fallback')).toBe('Acme'); + }); + + it('falls back to the supplied name when there is no match and not exactly one org', () => { + expect(resolveActiveOrgName([], null, 'Home Org')).toBe('Home Org'); + expect(resolveActiveOrgName([acme, beta], 'org-x', 'Home Org')).toBe('Home Org'); + }); + + it('returns null when there is nothing to show', () => { + expect(resolveActiveOrgName([], null, null)).toBeNull(); + }); +}); + +describe('OrgSwitcherView — multi-org', () => { + it('renders a switcher with an option per org and marks the active org', () => { + const html = renderToStaticMarkup( + createElement(OrgSwitcherView, { + orgs: [acme, beta], + activeOrgId: 'org-a', + fallbackName: null, + onSwitch: () => {}, + }), + ); + expect(html).toContain('data-mode="switcher"'); + expect(html).toContain('data-active-org-id="org-a"'); + expect(html).toContain('aria-label="Switch organization"'); + expect(html).toContain('value="org-a"'); + expect(html).toContain('value="org-b"'); + expect(html).toContain('Acme'); + expect(html).toContain('Beta'); + }); + + it('disables the select while a switch is pending', () => { + const html = renderToStaticMarkup( + createElement(OrgSwitcherView, { + orgs: [acme, beta], + activeOrgId: 'org-a', + fallbackName: null, + pending: true, + onSwitch: () => {}, + }), + ); + expect(html).toContain('disabled'); + }); +}); + +describe('OrgSwitcherView — single / zero org (inert banner)', () => { + it('renders the inert banner with the active org name for a single membership', () => { + const html = renderToStaticMarkup( + createElement(OrgSwitcherView, { + orgs: [acme], + activeOrgId: 'org-a', + fallbackName: null, + onSwitch: () => {}, + }), + ); + expect(html).toContain('data-mode="static"'); + expect(html).not.toContain('data-mode="switcher"'); + expect(html).toContain('Acme'); + }); + + it('uses the fallback name when there are no memberships yet (loading)', () => { + const html = renderToStaticMarkup( + createElement(OrgSwitcherView, { + orgs: [], + activeOrgId: null, + fallbackName: 'Home Org', + onSwitch: () => {}, + }), + ); + expect(html).toContain('data-mode="static"'); + expect(html).toContain('Home Org'); + }); + + it('does not invoke onSwitch during a pure render', () => { + const onSwitch = vi.fn(); + renderToStaticMarkup( + createElement(OrgSwitcherView, { + orgs: [acme, beta], + activeOrgId: 'org-a', + fallbackName: null, + onSwitch, + }), + ); + expect(onSwitch).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/web/users-table-membership.test.ts b/tests/unit/web/users-table-membership.test.ts new file mode 100644 index 000000000..7c5305157 --- /dev/null +++ b/tests/unit/web/users-table-membership.test.ts @@ -0,0 +1,43 @@ +/** + * Tests for the membership-based member-list display model (spec 021 plan 4, + * AC #5). `UsersTable` itself wires react-query + radix dialogs, so the display + * contract is extracted into pure helpers and asserted here. + */ + +import { describe, expect, it } from 'vitest'; +import { + describeMemberRow, + roleVariant, +} from '../../../web/src/components/settings/users-table.js'; + +describe('roleVariant', () => { + it('maps roles to badge variants', () => { + expect(roleVariant('superadmin')).toBe('destructive'); + expect(roleVariant('admin')).toBe('default'); + expect(roleVariant('member')).toBe('secondary'); + expect(roleVariant('anything-else')).toBe('secondary'); + }); +}); + +describe('describeMemberRow', () => { + it('surfaces both the account role and the per-org role independently', () => { + const summary = describeMemberRow({ globalRole: 'member', role: 'admin', isGuest: false }); + expect(summary.accountRole).toBe('member'); + expect(summary.orgRole).toBe('admin'); + }); + + it('marks cross-home accounts as guests', () => { + expect(describeMemberRow({ globalRole: 'member', role: 'member', isGuest: true }).isGuest).toBe( + true, + ); + }); + + it('defaults isGuest to false when the field is absent', () => { + expect(describeMemberRow({ globalRole: 'admin', role: 'admin' }).isGuest).toBe(false); + }); + + it('flags global superadmins as manage-via-CLI only', () => { + expect(describeMemberRow({ globalRole: 'superadmin', role: 'admin' }).manageViaCli).toBe(true); + expect(describeMemberRow({ globalRole: 'admin', role: 'admin' }).manageViaCli).toBe(false); + }); +}); diff --git a/web/src/components/layout/org-switcher.tsx b/web/src/components/layout/org-switcher.tsx new file mode 100644 index 000000000..06f106c0c --- /dev/null +++ b/web/src/components/layout/org-switcher.tsx @@ -0,0 +1,149 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Building2 } from 'lucide-react'; +import { NativeSelect } from '@/components/ui/native-select.js'; +import { trpc, trpcClient } from '@/lib/trpc.js'; + +/** + * Membership-based active-org switcher (spec 021 plan 4). + * + * Distinct from the superadmin cross-org switcher in `sidebar.tsx`, which + * selects ANY org via the client-side `x-org-context` header. This switcher is + * for regular (non-superadmin) users who belong to more than one org: it lists + * the user's memberships via `auth.listMyOrgs` and switches the SESSION's + * `active_org_id` server-side via `auth.setActiveOrg`. The active org then drives + * `computeEffectiveOrgId` on every subsequent request (see + * `docs/architecture/01-services.md`), so a switch invalidates every cached + * query to refetch the whole dashboard against the new org. + * + * Single-org users (≤1 membership) see no switcher — just the inert org name + * banner (spec AC #9). + */ + +export interface MyOrg { + readonly id: string; + readonly name: string; + /** The user's per-org role in this org ('member' | 'admin'). */ + readonly role: string; +} + +/** + * The switcher only renders when the user belongs to more than one org. With a + * single membership there is nothing to switch to, so the banner is inert + * (spec AC #9 — hidden·inert for single-org). + */ +export function shouldShowOrgSwitcher(orgs: ReadonlyArray<{ id: string }>): boolean { + return orgs.length > 1; +} + +/** + * Resolve the org name to display in the inert banner: prefer the active org + * (matched by id), fall back to the only membership, then to the caller-supplied + * name (the effective org name from `auth.me`). + */ +export function resolveActiveOrgName( + orgs: ReadonlyArray, + activeOrgId: string | null, + fallback: string | null, +): string | null { + const match = orgs.find((o) => o.id === activeOrgId); + if (match) return match.name; + if (orgs.length === 1) return orgs[0].name; + return fallback ?? null; +} + +/** Inert org-name banner shown when there is nothing to switch to. */ +export function OrgNameBanner({ name }: { name: string | null }) { + return ( +
+ + {name ?? 'Loading...'} +
+ ); +} + +export interface OrgSwitcherViewProps { + readonly orgs: ReadonlyArray; + readonly activeOrgId: string | null; + readonly fallbackName: string | null; + readonly pending?: boolean; + readonly onSwitch: (orgId: string) => void; +} + +/** + * Presentational switcher. Pure (no hooks) so it renders under + * `renderToStaticMarkup` in unit tests; uses the SSR-safe `NativeSelect` rather + * than the radix `Select` for the same reason. + */ +export function OrgSwitcherView({ + orgs, + activeOrgId, + fallbackName, + pending, + onSwitch, +}: OrgSwitcherViewProps) { + if (!shouldShowOrgSwitcher(orgs)) { + return ; + } + + return ( +
+ + onSwitch(e.target.value)} + className="h-14 rounded-none border-0 bg-transparent pl-10 pr-3 text-sm font-semibold focus-visible:ring-0" + > + {orgs.map((org) => ( + + ))} + +
+ ); +} + +/** + * Container: wires the membership list, the current active org (from + * `auth.me.effectiveOrgId`), and the switch mutation. On success it invalidates + * every query so the whole dashboard refetches against the new active org. + */ +export function OrgSwitcher({ fallbackName }: { fallbackName: string | null }) { + const queryClient = useQueryClient(); + const meQuery = useQuery({ ...trpc.auth.me.queryOptions(), retry: false }); + const myOrgsQuery = useQuery(trpc.auth.listMyOrgs.queryOptions()); + const orgs = myOrgsQuery.data ?? []; + const activeOrgId = meQuery.data?.effectiveOrgId ?? null; + + const switchMutation = useMutation({ + mutationFn: (orgId: string) => trpcClient.auth.setActiveOrg.mutate({ orgId }), + onSuccess: () => { + // The active org now lives in the session server-side, so every + // org-scoped query (and auth.me) must refetch to reflect the new org. + queryClient.invalidateQueries(); + }, + }); + + return ( + { + if (orgId !== activeOrgId) switchMutation.mutate(orgId); + }} + /> + ); +} diff --git a/web/src/components/layout/sidebar.tsx b/web/src/components/layout/sidebar.tsx index a8c84fcd6..7ade0e166 100644 --- a/web/src/components/layout/sidebar.tsx +++ b/web/src/components/layout/sidebar.tsx @@ -14,6 +14,7 @@ import { Zap, } from 'lucide-react'; import { useEffect, useState } from 'react'; +import { OrgNameBanner, OrgSwitcher } from '@/components/layout/org-switcher.js'; import { ProjectFormDialog } from '@/components/projects/project-form-dialog.js'; import { Select, @@ -145,30 +146,34 @@ function OrgBranding({ user }: { user: SidebarProps['user'] }) { const { effectiveOrgId, availableOrgs, orgName, switchOrg } = useOrgContext(); const isSuperadmin = user?.role === 'superadmin'; - if (isSuperadmin && availableOrgs && availableOrgs.length > 1 && effectiveOrgId) { - return ( - - ); + // Superadmin: client-side cross-org switcher via the `x-org-context` header + // (spec AC #7). Unchanged — superadmins discover every org, not just their + // memberships. + if (isSuperadmin) { + if (availableOrgs && availableOrgs.length > 1 && effectiveOrgId) { + return ( + + ); + } + return ; } - return ( -
- - {orgName ?? 'Loading...'} -
- ); + // Everyone else: membership-based active-org switcher (spec 021 plan 4). The + // switcher renders only for multi-org members; single-org users get the inert + // banner (spec AC #9). It manages its own list/active-org/switch wiring. + return ; } export function Sidebar({ user }: SidebarProps) { diff --git a/web/src/components/settings/add-to-org-dialog.tsx b/web/src/components/settings/add-to-org-dialog.tsx new file mode 100644 index 000000000..60021765e --- /dev/null +++ b/web/src/components/settings/add-to-org-dialog.tsx @@ -0,0 +1,193 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog.js'; +import { Input } from '@/components/ui/input.js'; +import { Label } from '@/components/ui/label.js'; +import { NativeSelect } from '@/components/ui/native-select.js'; +import { trpc, trpcClient } from '@/lib/trpc.js'; + +/** + * "Add existing account to this org" form (spec 021 plan 4, AC #1). + * + * Grants an already-registered CASCADE account a membership in the effective org + * via `users.addExistingUserToOrg`, rather than creating a duplicate account. + * The complementary path — creating a brand-new account — lives in + * `UserFormDialog`; when that email already exists the backend returns a typed + * CONFLICT which `UserFormDialog` already surfaces inline. This form surfaces the + * NOT_FOUND envelope (no account owns the email → create one first) inline. + */ + +export type OrgRole = 'member' | 'admin'; + +export interface AddToOrgResult { + readonly email: string; + readonly role: string; + /** True when the account was already a member and only its role was updated. */ + readonly alreadyMember: boolean; +} + +/** + * Human-readable confirmation for a successful grant. Distinguishes a fresh grant + * from an idempotent re-grant (which updates the per-org role) so the admin knows + * exactly what changed. + */ +export function formatAddToOrgSuccess(result: AddToOrgResult): string { + if (result.alreadyMember) { + return `Updated ${result.email}'s role to ${result.role} in this organization.`; + } + return `Added ${result.email} to this organization as ${result.role}.`; +} + +export interface AddToOrgFormProps { + readonly email: string; + readonly role: OrgRole; + readonly onEmailChange: (value: string) => void; + readonly onRoleChange: (value: OrgRole) => void; + readonly onSubmit: () => void; + readonly onCancel: () => void; + readonly pending?: boolean; + readonly errorMessage?: string | null; + readonly successMessage?: string | null; +} + +/** + * Presentational form. Pure (no hooks) and built from SSR-safe primitives so it + * renders under `renderToStaticMarkup` in unit tests. + */ +export function AddToOrgForm({ + email, + role, + onEmailChange, + onRoleChange, + onSubmit, + onCancel, + pending, + errorMessage, + successMessage, +}: AddToOrgFormProps) { + return ( +
{ + e.preventDefault(); + onSubmit(); + }} + className="space-y-4" + data-form="add-to-org" + > +

+ Grant an existing CASCADE account access to this organization. To create a brand-new + account, use New User instead. +

+
+ + onEmailChange(e.target.value)} + placeholder="jane@example.com" + required + /> +
+
+ + onRoleChange(e.target.value as OrgRole)} + > + + + +
+
+ + +
+ {errorMessage && ( +

+ {errorMessage} +

+ )} + {successMessage && ( +

+ {successMessage} +

+ )} +
+ ); +} + +export interface AddToOrgDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +/** Container: dialog shell + grant mutation + member-list invalidation. */ +export function AddToOrgDialog({ open, onOpenChange }: AddToOrgDialogProps) { + const queryClient = useQueryClient(); + const [email, setEmail] = useState(''); + const [role, setRole] = useState('member'); + const [successMessage, setSuccessMessage] = useState(null); + + const mutation = useMutation({ + mutationFn: () => trpcClient.users.addExistingUserToOrg.mutate({ email, role }), + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey: trpc.users.list.queryOptions().queryKey }); + setSuccessMessage(formatAddToOrgSuccess(result)); + // Clear the email so the admin can immediately grant another account; the + // success banner stays until they start typing again. + setEmail(''); + }, + }); + + // Reset transient state whenever the dialog is reopened so a prior grant's + // success/error banner never leaks into a fresh session. + // biome-ignore lint/correctness/useExhaustiveDependencies: open is the trigger; the setters and mutation.reset are stable + useEffect(() => { + if (open) { + setEmail(''); + setRole('member'); + setSuccessMessage(null); + mutation.reset(); + } + }, [open]); + + return ( + + + + Add existing account + + { + setEmail(value); + // Editing the email starts a new attempt: drop the prior result. + setSuccessMessage(null); + if (mutation.isError) mutation.reset(); + }} + onRoleChange={setRole} + onSubmit={() => mutation.mutate()} + onCancel={() => onOpenChange(false)} + pending={mutation.isPending} + errorMessage={mutation.isError ? mutation.error.message : null} + successMessage={successMessage} + /> + + + ); +} diff --git a/web/src/components/settings/users-table.tsx b/web/src/components/settings/users-table.tsx index 457443903..9a1ac12ff 100644 --- a/web/src/components/settings/users-table.tsx +++ b/web/src/components/settings/users-table.tsx @@ -46,12 +46,45 @@ interface User { updatedAt: string | null; } -function roleVariant(role: string): 'default' | 'secondary' | 'destructive' | 'outline' { +export function roleVariant(role: string): 'default' | 'secondary' | 'destructive' | 'outline' { if (role === 'superadmin') return 'destructive'; if (role === 'admin') return 'default'; return 'secondary'; } +/** + * Display model for one membership row (spec 021 plan 4, AC #5). Surfaces BOTH + * roles the listing returns: + * - `accountRole` — the global `users.role`; this is the column the editor still + * reads/writes (`users.update`). + * - `orgRole` — the PER-ORG membership role, rendered alongside so an admin can + * see a member's standing in *this* org even when it differs from the account + * role. + * + * `isGuest` marks an account whose home org is elsewhere (a cross-home member + * granted via add-to-org); `manageViaCli` flags global superadmins, whose + * accounts are intentionally not editable from the dashboard. + */ +export interface MemberRowSummary { + accountRole: string; + orgRole: string; + isGuest: boolean; + manageViaCli: boolean; +} + +export function describeMemberRow(member: { + role: string; + globalRole: string; + isGuest?: boolean; +}): MemberRowSummary { + return { + accountRole: member.globalRole, + orgRole: member.role, + isGuest: member.isGuest ?? false, + manageViaCli: member.globalRole === 'superadmin', + }; +} + export function UsersTable({ users }: { users: User[] }) { const queryClient = useQueryClient(); const [deleteId, setDeleteId] = useState(null); @@ -85,7 +118,8 @@ export function UsersTable({ users }: { users: User[] }) { Name Email - Role + Account + Org role Created @@ -93,58 +127,77 @@ export function UsersTable({ users }: { users: User[] }) { {users.length === 0 && ( - - No users yet + + No members yet )} - {users.map((u) => ( - - {u.name} - {u.email} - - {u.globalRole} - - - {u.createdAt ? new Date(u.createdAt).toLocaleDateString() : '—'} - - - {u.globalRole === 'superadmin' ? ( - Manage via CLI - ) : ( -
- - {u.isGuest ? ( - - ) : ( + Guest + + )} +
+
+ {u.email} + + {summary.accountRole} + + + {summary.orgRole} + + + {u.createdAt ? new Date(u.createdAt).toLocaleDateString() : '—'} + + + {summary.manageViaCli ? ( + Manage via CLI + ) : ( +
- )} -
- )} -
-
- ))} + {u.isGuest ? ( + + ) : ( + + )} +
+ )} +
+ + ); + })} diff --git a/web/src/routes/settings/users.tsx b/web/src/routes/settings/users.tsx index 88a61021d..03bf17aed 100644 --- a/web/src/routes/settings/users.tsx +++ b/web/src/routes/settings/users.tsx @@ -1,6 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import { createRoute } from '@tanstack/react-router'; import { useState } from 'react'; +import { AddToOrgDialog } from '@/components/settings/add-to-org-dialog.js'; import { UserFormDialog } from '@/components/settings/user-form-dialog.js'; import { UsersTable } from '@/components/settings/users-table.js'; import { trpc } from '@/lib/trpc.js'; @@ -8,6 +9,7 @@ import { rootRoute } from '../__root.js'; function UsersPage() { const [createOpen, setCreateOpen] = useState(false); + const [addExistingOpen, setAddExistingOpen] = useState(false); const usersQuery = useQuery(trpc.users.list.queryOptions()); return ( @@ -16,16 +18,25 @@ function UsersPage() {

Users

- Manage organization users and their roles. + Manage organization members and their roles.

- +
+ + +
{usersQuery.isLoading && ( @@ -41,6 +52,7 @@ function UsersPage() { {usersQuery.data && } + ); } From d28d377e4a7707ffa2eb453317f05aae3682bb46 Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Thu, 25 Jun 2026 10:30:29 +0200 Subject: [PATCH 10/18] fix(review): re-resolve work item from live PR when JIRA key added after review request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review work-item fallback resolves the JIRA key from the webhook PR snapshot captured at review_requested time. The GitHub worker re-dispatches with that same stored payload, so a key added to the PR description *after* review is requested is never seen — the linked issue gets no progress comment and no image pre-fetch, even though the agent later finds the key in the diff on its own. Re-resolve from live PR state at the shared execution chokepoint (createAgentExecutionContext), before budget / persistence / progress-comment / image pre-fetch consume the work item. New best-effort helper reresolveReviewWorkItemFromFreshPR fetches the PR fresh and reuses the existing resolveWorkItemIdWithFallback (branch / title / last body line + provider verify). Scoped tightly: review agent only, JIRA only, only when dispatch resolved nothing and the PR/repo are known. Any failure (missing GitHub/PM scope, GitHub error) degrades to prior behavior. One insertion covers all three review dispatch routes (review-requested, pr-opened, check-suite-success). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011tKnnXDiRkMJMmESXywmqm --- .../shared/agent-execution-runtime.ts | 24 ++- src/triggers/shared/agent-work-items.ts | 66 +++++++++ .../triggers/shared/agent-execution.test.ts | 106 ++++++++++++++ .../triggers/shared/agent-work-items.test.ts | 138 ++++++++++++++++++ 4 files changed, 333 insertions(+), 1 deletion(-) diff --git a/src/triggers/shared/agent-execution-runtime.ts b/src/triggers/shared/agent-execution-runtime.ts index ee83dcb40..2b72b991f 100644 --- a/src/triggers/shared/agent-execution-runtime.ts +++ b/src/triggers/shared/agent-execution-runtime.ts @@ -11,6 +11,7 @@ import { linkPRPostExecution, persistPreRunWorkItems, prepareAgentWorkItem, + reresolveReviewWorkItemFromFreshPR, } from './agent-work-items.js'; async function loadLifecycleHooks(agentType: string): Promise { @@ -40,7 +41,28 @@ export async function createAgentExecutionContext( const pmConfig = resolveProjectPMConfig(project); const lifecycle = new PMLifecycleManager(pmProvider, pmConfig); const lifecycleHooks = await loadLifecycleHooks(result.agentType); - const { workItemId, agentInput } = await prepareAgentWorkItem(result, project.id); + let { workItemId, agentInput } = await prepareAgentWorkItem(result, project.id); + + // Race fix: dispatch resolves the work item from the webhook PR snapshot. If a + // human adds the JIRA key to the PR description after requesting review, that + // snapshot misses it. Re-resolve from live PR state here — before budget / + // persistence / progress-comment / image pre-fetch consume the work item — so a + // late-added key still links end-to-end. Deliberately mutates the + // execution-local `result` so persistPreRunWorkItems records the resolved + // id + display data on the PR link. + const reresolved = await reresolveReviewWorkItemFromFreshPR(result, project, workItemId); + if (reresolved) { + workItemId = reresolved.workItemId; + result.workItemId = reresolved.workItemId; + result.workItemUrl = reresolved.workItemUrl ?? result.workItemUrl; + result.workItemTitle = reresolved.workItemTitle ?? result.workItemTitle; + agentInput = { + ...agentInput, + workItemId: reresolved.workItemId, + workItemUrl: reresolved.workItemUrl ?? agentInput.workItemUrl, + workItemTitle: reresolved.workItemTitle ?? agentInput.workItemTitle, + }; + } return { result, diff --git a/src/triggers/shared/agent-work-items.ts b/src/triggers/shared/agent-work-items.ts index 8a5ac5f77..9cd6bf10d 100644 --- a/src/triggers/shared/agent-work-items.ts +++ b/src/triggers/shared/agent-work-items.ts @@ -102,6 +102,72 @@ export async function persistPreRunWorkItems( } } +/** + * Re-derive a review's work item from LIVE PR state when dispatch-time resolution + * came up empty. + * + * The router (and the GitHub worker's re-dispatch) resolve the work item from the + * webhook payload's PR snapshot, captured at `review_requested` time. If a human + * requests review and THEN edits the PR description to add the JIRA key — a natural + * workflow — that snapshot misses it, so the issue never gets the progress comment + * or image pre-fetch even though the agent later reads the diff. This re-resolves + * against the live PR just before the work-item-dependent setup runs. + * + * Scoped tightly: review agent only, JIRA only, only when nothing was resolved and + * the PR/repo are known. Best-effort — any failure (missing GitHub/PM scope, + * GitHub error) returns `null` and the run proceeds exactly as before. Mirrors + * `linkPRPostExecution`'s dynamic `githubClient` import so this code path stays out + * of the module graph for callers that never hit it. + * + * Must run inside `withGitHubToken` + `withPMProvider` scope (the execution + * pipeline's contract — see `runAgentExecutionPipeline`). + */ +export async function reresolveReviewWorkItemFromFreshPR( + result: TriggerResult, + project: ProjectConfig, + currentWorkItemId: string | undefined, +): Promise<{ workItemId: string; workItemUrl?: string; workItemTitle?: string } | null> { + if (currentWorkItemId) return null; + if (result.agentType !== 'review') return null; + if (project.pm?.type !== 'jira') return null; + if (!result.prNumber || !project.repo) return null; + + try { + const { githubClient } = await import('../../github/client.js'); + const { resolveWorkItemIdWithFallback, resolveWorkItemDisplayData } = await import( + '../github/utils.js' + ); + const { owner, repo } = parseRepoFullName(project.repo); + const pr = await githubClient.getPR(owner, repo, result.prNumber); + + const workItemId = await resolveWorkItemIdWithFallback(project, result.prNumber, { + branch: pr.headRef, + title: pr.title, + body: pr.body, + }); + if (!workItemId) return null; + + const display = await resolveWorkItemDisplayData(workItemId); + logger.info('Re-resolved review work item from fresh PR state', { + projectId: project.id, + prNumber: result.prNumber, + workItemId, + }); + return { + workItemId, + workItemUrl: display.workItemUrl, + workItemTitle: display.workItemTitle, + }; + } catch (err) { + logger.warn('Fresh-PR review work-item re-resolution failed (best-effort)', { + projectId: project.id, + prNumber: result.prNumber, + error: String(err), + }); + return null; + } +} + export async function linkPRPostExecution( agentResult: AgentResult & { prUrl: string }, project: ProjectConfig & { repo: string }, diff --git a/tests/unit/triggers/shared/agent-execution.test.ts b/tests/unit/triggers/shared/agent-execution.test.ts index 672bfb742..0d8f6c178 100644 --- a/tests/unit/triggers/shared/agent-execution.test.ts +++ b/tests/unit/triggers/shared/agent-execution.test.ts @@ -26,6 +26,8 @@ const { mockGetAgentProfile, mockClaimReviewDispatch, mockBuildReviewDispatchKey, + mockResolveWorkItemIdWithFallback, + mockResolveWorkItemDisplayData, } = vi.hoisted(() => ({ mockRunAgent: vi.fn(), mockGetPMProvider: vi.fn(), @@ -63,6 +65,8 @@ const { mockGetAgentProfile: vi.fn().mockResolvedValue({ lifecycleHooks: {} }), mockClaimReviewDispatch: vi.fn().mockReturnValue(true), mockBuildReviewDispatchKey: vi.fn().mockReturnValue('acme/myapp:42:abc123'), + mockResolveWorkItemIdWithFallback: vi.fn().mockResolvedValue(undefined), + mockResolveWorkItemDisplayData: vi.fn().mockResolvedValue({}), })); vi.mock('../../../../src/agents/registry.js', () => ({ @@ -145,6 +149,11 @@ vi.mock('../../../../src/triggers/github/review-dispatch-dedup.js', () => ({ buildReviewDispatchKey: (...args: unknown[]) => mockBuildReviewDispatchKey(...args), })); +vi.mock('../../../../src/triggers/github/utils.js', () => ({ + resolveWorkItemIdWithFallback: mockResolveWorkItemIdWithFallback, + resolveWorkItemDisplayData: mockResolveWorkItemDisplayData, +})); + import { createWorkItem, linkPRToWorkItem, @@ -1131,3 +1140,100 @@ describe('post-completion review dispatch (via runAgentExecutionPipeline)', () = ); }); }); + +// --------------------------------------------------------------------------- +// Fresh-PR work-item re-resolution for review (via runAgentExecutionPipeline) +// --------------------------------------------------------------------------- + +describe('fresh-PR work-item re-resolution for review (via runAgentExecutionPipeline)', () => { + const JIRA_PROJECT = { + id: 'project-1', + repo: 'acme/myapp', + pm: { type: 'jira' }, + jira: { projectKey: 'PROJ' }, + } as unknown as Parameters[0]['project']; + + beforeEach(() => { + vi.clearAllMocks(); + mockCreatePMProvider.mockReturnValue({}); + mockResolveProjectPMConfig.mockReturnValue(PM_CONFIG); + mockValidateIntegrations.mockResolvedValue({ valid: true, errors: [] }); + mockCheckBudgetExceeded.mockResolvedValue(null); + mockHandleAgentResultArtifacts.mockResolvedValue(undefined); + mockShouldTriggerDebug.mockResolvedValue(null); + mockRunAgent.mockResolvedValue({ success: true, output: '', runId: 'run-1' }); + mockParseRepoFullName.mockReturnValue({ owner: 'acme', repo: 'myapp' }); + mockLookupWorkItemForPR.mockResolvedValue(null); + mockGithubClient.getPR.mockResolvedValue({ + headRef: 'chore/setup', + title: 'chore: setup', + body: 'Description.\n\nPROJ-7', + }); + mockResolveWorkItemDisplayData.mockResolvedValue({}); + }); + + it('links the freshly-derived key when dispatch left workItemId empty', async () => { + mockResolveWorkItemIdWithFallback.mockResolvedValueOnce('PROJ-7'); + mockResolveWorkItemDisplayData.mockResolvedValueOnce({ + workItemUrl: 'https://jira/PROJ-7', + workItemTitle: 'Do the thing', + }); + + await runAgentExecutionPipeline( + { + agentType: 'review', + agentInput: { prNumber: 42 }, + prNumber: 42, + prUrl: 'https://github.com/acme/myapp/pull/42', + }, + JIRA_PROJECT, + CONFIG, + ); + + // The agent receives the freshly-resolved work item. + expect(mockRunAgent).toHaveBeenCalledWith( + 'review', + expect.objectContaining({ workItemId: 'PROJ-7' }), + ); + // The pre-run link row is written with the freshly-resolved id (not null). + expect(vi.mocked(linkPRToWorkItem)).toHaveBeenCalledWith( + 'project-1', + 'acme/myapp', + 42, + 'PROJ-7', + expect.objectContaining({ workItemUrl: 'https://jira/PROJ-7' }), + ); + }); + + it('does not re-resolve when dispatch already supplied a workItemId', async () => { + await runAgentExecutionPipeline( + { + agentType: 'review', + agentInput: { prNumber: 42, workItemId: 'PROJ-1' }, + prNumber: 42, + workItemId: 'PROJ-1', + prUrl: 'https://github.com/acme/myapp/pull/42', + }, + JIRA_PROJECT, + CONFIG, + ); + + expect(mockResolveWorkItemIdWithFallback).not.toHaveBeenCalled(); + expect(mockGithubClient.getPR).not.toHaveBeenCalled(); + expect(mockRunAgent).toHaveBeenCalledWith( + 'review', + expect.objectContaining({ workItemId: 'PROJ-1' }), + ); + }); + + it('does not re-resolve for a non-review agent on a JIRA project', async () => { + await runAgentExecutionPipeline( + { agentType: 'implementation', agentInput: { prNumber: 42 }, prNumber: 42 }, + JIRA_PROJECT, + CONFIG, + ); + + expect(mockResolveWorkItemIdWithFallback).not.toHaveBeenCalled(); + expect(mockGithubClient.getPR).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/triggers/shared/agent-work-items.test.ts b/tests/unit/triggers/shared/agent-work-items.test.ts index 9f6b5aee2..19003538f 100644 --- a/tests/unit/triggers/shared/agent-work-items.test.ts +++ b/tests/unit/triggers/shared/agent-work-items.test.ts @@ -7,6 +7,8 @@ const { mockUpdateRunPRNumber, mockGithubClient, mockParseRepoFullName, + mockResolveWorkItemIdWithFallback, + mockResolveWorkItemDisplayData, mockLogger, } = vi.hoisted(() => ({ mockCreateWorkItem: vi.fn().mockResolvedValue(undefined), @@ -17,6 +19,8 @@ const { getPR: vi.fn().mockResolvedValue({ title: 'feat: linked PR' }), }, mockParseRepoFullName: vi.fn().mockReturnValue({ owner: 'acme', repo: 'myapp' }), + mockResolveWorkItemIdWithFallback: vi.fn().mockResolvedValue(undefined), + mockResolveWorkItemDisplayData: vi.fn().mockResolvedValue({}), mockLogger: { warn: vi.fn(), info: vi.fn(), @@ -43,6 +47,11 @@ vi.mock('../../../../src/utils/repo.js', () => ({ parseRepoFullName: mockParseRepoFullName, })); +vi.mock('../../../../src/triggers/github/utils.js', () => ({ + resolveWorkItemIdWithFallback: mockResolveWorkItemIdWithFallback, + resolveWorkItemDisplayData: mockResolveWorkItemDisplayData, +})); + vi.mock('../../../../src/utils/logging.js', () => ({ logger: mockLogger, })); @@ -51,6 +60,7 @@ import { linkPRPostExecution, persistPreRunWorkItems, prepareAgentWorkItem, + reresolveReviewWorkItemFromFreshPR, resolveWorkItemId, } from '../../../../src/triggers/shared/agent-work-items.js'; import type { TriggerResult } from '../../../../src/triggers/types.js'; @@ -337,4 +347,132 @@ describe('agent-work-items', () => { ); }); }); + + describe('reresolveReviewWorkItemFromFreshPR', () => { + const JIRA_PROJECT = { + id: 'project-1', + repo: 'acme/myapp', + pm: { type: 'jira' }, + jira: { projectKey: 'PROJ' }, + } as unknown as ProjectConfig; + + beforeEach(() => { + mockResolveWorkItemIdWithFallback.mockReset().mockResolvedValue(undefined); + mockResolveWorkItemDisplayData.mockReset().mockResolvedValue({}); + mockGithubClient.getPR.mockResolvedValue({ + headRef: 'chore/setup', + title: 'chore: setup', + body: 'Some description.\n\nPROJ-7', + }); + mockParseRepoFullName.mockReturnValue({ owner: 'acme', repo: 'myapp' }); + }); + + it('re-resolves from the live PR when dispatch left workItemId empty', async () => { + mockResolveWorkItemIdWithFallback.mockResolvedValueOnce('PROJ-7'); + mockResolveWorkItemDisplayData.mockResolvedValueOnce({ + workItemUrl: 'https://jira/PROJ-7', + workItemTitle: 'Do the thing', + }); + + const out = await reresolveReviewWorkItemFromFreshPR( + { agentType: 'review', agentInput: {}, prNumber: 42 }, + JIRA_PROJECT, + undefined, + ); + + expect(mockGithubClient.getPR).toHaveBeenCalledWith('acme', 'myapp', 42); + expect(mockResolveWorkItemIdWithFallback).toHaveBeenCalledWith(JIRA_PROJECT, 42, { + branch: 'chore/setup', + title: 'chore: setup', + body: 'Some description.\n\nPROJ-7', + }); + expect(out).toEqual({ + workItemId: 'PROJ-7', + workItemUrl: 'https://jira/PROJ-7', + workItemTitle: 'Do the thing', + }); + }); + + it('no-ops (no PR fetch) when a workItemId was already resolved at dispatch', async () => { + const out = await reresolveReviewWorkItemFromFreshPR( + { agentType: 'review', agentInput: {}, prNumber: 42 }, + JIRA_PROJECT, + 'PROJ-1', + ); + + expect(out).toBeNull(); + expect(mockGithubClient.getPR).not.toHaveBeenCalled(); + }); + + it('no-ops for non-review agent types', async () => { + const out = await reresolveReviewWorkItemFromFreshPR( + { agentType: 'implementation', agentInput: {}, prNumber: 42 }, + JIRA_PROJECT, + undefined, + ); + + expect(out).toBeNull(); + expect(mockGithubClient.getPR).not.toHaveBeenCalled(); + }); + + it('no-ops for non-JIRA PM providers', async () => { + const out = await reresolveReviewWorkItemFromFreshPR( + { agentType: 'review', agentInput: {}, prNumber: 42 }, + PROJECT, // trello fixture + undefined, + ); + + expect(out).toBeNull(); + expect(mockGithubClient.getPR).not.toHaveBeenCalled(); + }); + + it('no-ops when prNumber or repo is missing', async () => { + const noRepo = { id: 'project-1', pm: { type: 'jira' } } as unknown as ProjectConfig; + + expect( + await reresolveReviewWorkItemFromFreshPR( + { agentType: 'review', agentInput: {}, prNumber: 42 }, + noRepo, + undefined, + ), + ).toBeNull(); + expect( + await reresolveReviewWorkItemFromFreshPR( + { agentType: 'review', agentInput: {} }, + JIRA_PROJECT, + undefined, + ), + ).toBeNull(); + expect(mockGithubClient.getPR).not.toHaveBeenCalled(); + }); + + it('returns null (no display lookup) when the live PR yields no key', async () => { + mockResolveWorkItemIdWithFallback.mockResolvedValueOnce(undefined); + + const out = await reresolveReviewWorkItemFromFreshPR( + { agentType: 'review', agentInput: {}, prNumber: 42 }, + JIRA_PROJECT, + undefined, + ); + + expect(out).toBeNull(); + expect(mockResolveWorkItemDisplayData).not.toHaveBeenCalled(); + }); + + it('returns null and warns when the PR fetch throws (best-effort)', async () => { + mockGithubClient.getPR.mockRejectedValueOnce(new Error('github down')); + + const out = await reresolveReviewWorkItemFromFreshPR( + { agentType: 'review', agentInput: {}, prNumber: 42 }, + JIRA_PROJECT, + undefined, + ); + + expect(out).toBeNull(); + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Fresh-PR review work-item re-resolution failed (best-effort)', + expect.objectContaining({ projectId: 'project-1', prNumber: 42 }), + ); + }); + }); }); From 75e71e123b0bd8e1d85d28023b9f0d9c97f35f2b Mon Sep 17 00:00:00 2001 From: aaight Date: Thu, 25 Jun 2026 10:49:36 +0200 Subject: [PATCH 11/18] feat(config): add shared UpdateChannel type and gating helpers (#1444) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce src/config/updateChannel.ts as the single source of truth for the update-channel concept: a const catalog (none/scm-only/pm-only/both), the UpdateChannel type, DEFAULT_UPDATE_CHANNEL ('both'), a Zod enum schema, the resolveUpdateChannel(project, agentType) resolver, the PM/SCM posting matrix helpers (isPmPostingEnabled / isScmPostingEnabled), the communication-only gadget name lists (PM_POSTING_GADGETS / SCM_POSTING_GADGETS), and filterPostingGadgetNames(). Pure, dependency-free foundation (Zod only) — no DB/UI/gating wiring yet, so nothing else consumes it and the codebase stays green. Covered by tests/unit/config/updateChannel.test.ts. Co-authored-by: Cascade Bot Co-authored-by: Claude Opus 4.8 --- src/config/updateChannel.ts | 123 +++++++++++++++ tests/unit/config/updateChannel.test.ts | 190 ++++++++++++++++++++++++ 2 files changed, 313 insertions(+) create mode 100644 src/config/updateChannel.ts create mode 100644 tests/unit/config/updateChannel.test.ts diff --git a/src/config/updateChannel.ts b/src/config/updateChannel.ts new file mode 100644 index 000000000..24b3feba9 --- /dev/null +++ b/src/config/updateChannel.ts @@ -0,0 +1,123 @@ +import { z } from 'zod'; + +/** + * Update-channel catalog — the single source of truth for *where* a CASCADE + * agent is allowed to post communication-only status updates back to humans. + * + * A channel gates two independent posting surfaces: + * - **PM** — comments on the work item (Trello card / JIRA issue / Linear issue). + * - **SCM** — comments and reviews on the GitHub pull request. + * + * | channel | PM posting | SCM posting | + * |------------|:----------:|:-----------:| + * | `none` | ❌ | ❌ | + * | `pm-only` | ✅ | ❌ | + * | `scm-only` | ❌ | ✅ | + * | `both` | ✅ | ✅ | + * + * The default is `both`, which preserves the historical behavior where agents + * post everywhere. + * + * This module is intentionally pure and dependency-free (Zod only). Wiring the + * resolver / gating helpers into config mapping, the DB, the API, the CLI, or + * the UI is the job of later stories — everything else imports from here so the + * channel semantics have exactly one home. + */ +export const UPDATE_CHANNELS = ['none', 'scm-only', 'pm-only', 'both'] as const; + +/** A single update-channel value. */ +export type UpdateChannel = (typeof UPDATE_CHANNELS)[number]; + +/** Channel used when a project / agent does not specify one. */ +export const DEFAULT_UPDATE_CHANNEL: UpdateChannel = 'both'; + +/** Zod enum for validating persisted / API-supplied channel values. */ +export const UpdateChannelSchema = z.enum(UPDATE_CHANNELS); + +/** + * Minimal structural shape that {@link resolveUpdateChannel} reads from a + * project. + * + * `agentUpdateChannels` is deliberately optional and is NOT yet part of the + * persisted `ProjectConfig` schema — adding the column / config field is a + * later story. Typing against this narrow interface keeps this foundational + * module decoupled from the central schema while remaining structurally + * compatible with the future `ProjectConfig` shape. + */ +export interface ProjectWithUpdateChannels { + /** Per-agent-type channel overrides, keyed by agent type. */ + agentUpdateChannels?: Record; +} + +/** + * Resolve the update channel for a given agent type on a project. + * + * Reads `project.agentUpdateChannels?.[agentType]` and falls back to + * {@link DEFAULT_UPDATE_CHANNEL} (`both`) when the project has no map, no entry + * for the agent type, or an `undefined` entry. + */ +export function resolveUpdateChannel( + project: ProjectWithUpdateChannels, + agentType: string, +): UpdateChannel { + return project.agentUpdateChannels?.[agentType] ?? DEFAULT_UPDATE_CHANNEL; +} + +/** True when the channel permits posting PM (work-item) comments. */ +export function isPmPostingEnabled(channel: UpdateChannel): boolean { + return channel === 'pm-only' || channel === 'both'; +} + +/** True when the channel permits posting SCM (pull-request) comments / reviews. */ +export function isScmPostingEnabled(channel: UpdateChannel): boolean { + return channel === 'scm-only' || channel === 'both'; +} + +/** + * Communication-only gadget names, grouped by posting surface. + * + * These are the *only* gadgets the update channel gates: they exist purely to + * post human-facing status updates, so silencing them never stops an agent + * from doing real work (reading code, opening PRs, moving cards, etc.). Action + * gadgets such as `CreatePR`, `MoveWorkItem`, and `UpdateWorkItem` are + * deliberately absent from these lists. + */ +export const PM_POSTING_GADGETS = ['PostComment'] as const; + +/** SCM (pull-request) communication-only gadget names. See {@link PM_POSTING_GADGETS}. */ +export const SCM_POSTING_GADGETS = [ + 'PostPRComment', + 'UpdatePRComment', + 'CreatePRReview', + 'ReplyToReviewComment', +] as const; + +const PM_POSTING_GADGET_NAMES: ReadonlySet = new Set(PM_POSTING_GADGETS); +const SCM_POSTING_GADGET_NAMES: ReadonlySet = new Set(SCM_POSTING_GADGETS); + +/** + * Drop the communication-only posting gadget names the channel disables. + * + * - When PM posting is off, {@link PM_POSTING_GADGETS} names are removed. + * - When SCM posting is off, {@link SCM_POSTING_GADGETS} names are removed. + * - Every other name (action gadgets, unknown names) passes through untouched. + * + * The input order is preserved and the input array is not mutated. + */ +export function filterPostingGadgetNames( + names: readonly string[], + channel: UpdateChannel, +): string[] { + const pmEnabled = isPmPostingEnabled(channel); + const scmEnabled = isScmPostingEnabled(channel); + + return names.filter((name) => { + if (!pmEnabled && PM_POSTING_GADGET_NAMES.has(name)) { + return false; + } + if (!scmEnabled && SCM_POSTING_GADGET_NAMES.has(name)) { + return false; + } + return true; + }); +} diff --git a/tests/unit/config/updateChannel.test.ts b/tests/unit/config/updateChannel.test.ts new file mode 100644 index 000000000..1339dc1f6 --- /dev/null +++ b/tests/unit/config/updateChannel.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from 'vitest'; + +import { + DEFAULT_UPDATE_CHANNEL, + filterPostingGadgetNames, + isPmPostingEnabled, + isScmPostingEnabled, + PM_POSTING_GADGETS, + resolveUpdateChannel, + SCM_POSTING_GADGETS, + UPDATE_CHANNELS, + type UpdateChannel, + UpdateChannelSchema, +} from '../../../src/config/updateChannel.js'; + +describe.concurrent('config/updateChannel', () => { + describe('UPDATE_CHANNELS', () => { + it('lists the four channels', () => { + expect(UPDATE_CHANNELS).toEqual(['none', 'scm-only', 'pm-only', 'both']); + }); + }); + + describe('DEFAULT_UPDATE_CHANNEL', () => { + it("defaults to 'both' (post everywhere — historical behavior)", () => { + expect(DEFAULT_UPDATE_CHANNEL).toBe('both'); + }); + + it('is one of the known channels', () => { + expect(UPDATE_CHANNELS).toContain(DEFAULT_UPDATE_CHANNEL); + }); + }); + + describe('UpdateChannelSchema', () => { + it('accepts every known channel', () => { + for (const channel of UPDATE_CHANNELS) { + expect(UpdateChannelSchema.parse(channel)).toBe(channel); + } + }); + + it('rejects unknown values', () => { + expect(UpdateChannelSchema.safeParse('all').success).toBe(false); + expect(UpdateChannelSchema.safeParse('').success).toBe(false); + expect(UpdateChannelSchema.safeParse(undefined).success).toBe(false); + }); + }); + + describe('resolveUpdateChannel', () => { + it('reads project.agentUpdateChannels[agentType]', () => { + const project = { + agentUpdateChannels: { implementation: 'pm-only' as UpdateChannel }, + }; + expect(resolveUpdateChannel(project, 'implementation')).toBe('pm-only'); + }); + + it('resolves each agent type independently', () => { + const project = { + agentUpdateChannels: { + implementation: 'scm-only' as UpdateChannel, + review: 'none' as UpdateChannel, + }, + }; + expect(resolveUpdateChannel(project, 'implementation')).toBe('scm-only'); + expect(resolveUpdateChannel(project, 'review')).toBe('none'); + }); + + it("falls back to 'both' when the project has no map", () => { + expect(resolveUpdateChannel({}, 'implementation')).toBe('both'); + }); + + it("falls back to 'both' when the agent type has no entry", () => { + const project = { + agentUpdateChannels: { review: 'none' as UpdateChannel }, + }; + expect(resolveUpdateChannel(project, 'implementation')).toBe('both'); + }); + + it("falls back to 'both' when the entry is undefined", () => { + const project = { + agentUpdateChannels: { implementation: undefined }, + }; + expect(resolveUpdateChannel(project, 'implementation')).toBe('both'); + }); + + it('uses DEFAULT_UPDATE_CHANNEL as the fallback', () => { + expect(resolveUpdateChannel({}, 'anything')).toBe(DEFAULT_UPDATE_CHANNEL); + }); + }); + + describe('posting matrix (isPmPostingEnabled / isScmPostingEnabled)', () => { + const matrix: Array<{ channel: UpdateChannel; pm: boolean; scm: boolean }> = [ + { channel: 'none', pm: false, scm: false }, + { channel: 'pm-only', pm: true, scm: false }, + { channel: 'scm-only', pm: false, scm: true }, + { channel: 'both', pm: true, scm: true }, + ]; + + it('covers every channel in UPDATE_CHANNELS', () => { + expect(matrix.map((row) => row.channel).sort()).toEqual([...UPDATE_CHANNELS].sort()); + }); + + for (const { channel, pm, scm } of matrix) { + it(`${channel} → PM:${pm ? '✅' : '❌'} SCM:${scm ? '✅' : '❌'}`, () => { + expect(isPmPostingEnabled(channel)).toBe(pm); + expect(isScmPostingEnabled(channel)).toBe(scm); + }); + } + }); + + describe('posting gadget lists', () => { + it('PM_POSTING_GADGETS is the work-item comment gadget', () => { + expect(PM_POSTING_GADGETS).toEqual(['PostComment']); + }); + + it('SCM_POSTING_GADGETS are the PR comment / review gadgets', () => { + expect(SCM_POSTING_GADGETS).toEqual([ + 'PostPRComment', + 'UpdatePRComment', + 'CreatePRReview', + 'ReplyToReviewComment', + ]); + }); + + it('PM and SCM posting gadgets do not overlap', () => { + const overlap = PM_POSTING_GADGETS.filter((name) => + (SCM_POSTING_GADGETS as readonly string[]).includes(name), + ); + expect(overlap).toEqual([]); + }); + }); + + describe('filterPostingGadgetNames', () => { + const ALL_POSTING = [...PM_POSTING_GADGETS, ...SCM_POSTING_GADGETS]; + + it("'both' keeps every posting gadget", () => { + expect(filterPostingGadgetNames(ALL_POSTING, 'both')).toEqual(ALL_POSTING); + }); + + it("'none' drops every posting gadget", () => { + expect(filterPostingGadgetNames(ALL_POSTING, 'none')).toEqual([]); + }); + + it("'pm-only' keeps PM posting and drops SCM posting", () => { + expect(filterPostingGadgetNames(ALL_POSTING, 'pm-only')).toEqual([...PM_POSTING_GADGETS]); + }); + + it("'scm-only' keeps SCM posting and drops PM posting", () => { + expect(filterPostingGadgetNames(ALL_POSTING, 'scm-only')).toEqual([...SCM_POSTING_GADGETS]); + }); + + it('never drops action (non-posting) gadget names', () => { + const actionGadgets = ['CreatePR', 'MoveWorkItem', 'UpdateWorkItem', 'ReadFile']; + for (const channel of UPDATE_CHANNELS) { + expect(filterPostingGadgetNames(actionGadgets, channel)).toEqual(actionGadgets); + } + }); + + it('drops only the disabled posting names while preserving order around them', () => { + const names = ['ReadFile', 'PostComment', 'CreatePR', 'PostPRComment', 'MoveWorkItem']; + expect(filterPostingGadgetNames(names, 'pm-only')).toEqual([ + 'ReadFile', + 'PostComment', + 'CreatePR', + 'MoveWorkItem', + ]); + expect(filterPostingGadgetNames(names, 'scm-only')).toEqual([ + 'ReadFile', + 'CreatePR', + 'PostPRComment', + 'MoveWorkItem', + ]); + expect(filterPostingGadgetNames(names, 'none')).toEqual([ + 'ReadFile', + 'CreatePR', + 'MoveWorkItem', + ]); + }); + + it('does not mutate the input array', () => { + const names = ['PostComment', 'PostPRComment']; + const snapshot = [...names]; + filterPostingGadgetNames(names, 'none'); + expect(names).toEqual(snapshot); + }); + + it('handles an empty list', () => { + expect(filterPostingGadgetNames([], 'both')).toEqual([]); + expect(filterPostingGadgetNames([], 'none')).toEqual([]); + }); + }); +}); From fca7f5797dea38ab2d6c2c3bf9c665143e6552c5 Mon Sep 17 00:00:00 2001 From: aaight Date: Thu, 25 Jun 2026 11:14:42 +0200 Subject: [PATCH 12/18] feat(config): persist agent update_channel and surface agentUpdateChannels (#1445) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the nullable `update_channel` column to `agent_configs` (migration 0055) and maps it into `ProjectConfig.agentUpdateChannels` so the resolved channel is available at runtime. NULL means inherit the default (`both`) — no backfill; existing projects are untouched. Read path only (no API writes or gating yet). - Migration 0055_agent_config_update_channel.sql + journal entry (idx 55) - Drizzle agentConfigs schema gains updateChannel: text('update_channel') - AgentConfigRow + buildAgentMaps accumulate updateChannels validated against UPDATE_CHANNELS (unknown values ignored) - ProjectConfigRaw/mapProjectRow set agentUpdateChannels via orUndefined; ProjectConfigSchema adds agentUpdateChannels record - Unit + integration round-trip tests (NULL resolves to both) MNG-1682 Co-authored-by: Cascade Bot Co-authored-by: Claude Opus 4.8 --- docs/architecture/09-database.md | 1 + src/config/schema.ts | 8 + .../0055_agent_config_update_channel.sql | 6 + src/db/migrations/meta/_journal.json | 7 + src/db/repositories/configMapper.ts | 21 ++- src/db/schema/agentConfigs.ts | 3 + tests/integration/db/configRepository.test.ts | 63 +++++++ tests/integration/helpers/seed.ts | 2 + .../unit/db/repositories/configMapper.test.ts | 154 ++++++++++++++++++ 9 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 src/db/migrations/0055_agent_config_update_channel.sql diff --git a/docs/architecture/09-database.md b/docs/architecture/09-database.md index 9b25b5369..2f6a4bf1e 100644 --- a/docs/architecture/09-database.md +++ b/docs/architecture/09-database.md @@ -82,6 +82,7 @@ erDiagram integer max_concurrency text system_prompt text task_prompt + text update_channel } agent_trigger_configs { diff --git a/src/config/schema.ts b/src/config/schema.ts index bb672e590..b593eb922 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -3,6 +3,7 @@ import { jiraConfigSchema } from '../integrations/pm/jira/config-schema.js'; import { linearConfigSchema } from '../integrations/pm/linear/config-schema.js'; import { trelloConfigSchema } from '../integrations/pm/trello/config-schema.js'; import { EngineSettingsSchema } from './engineSettings.js'; +import { UpdateChannelSchema } from './updateChannel.js'; export const PROJECT_DEFAULTS = { model: 'openrouter:google/gemini-3-flash-preview', @@ -67,6 +68,13 @@ export const ProjectConfigSchema = z.object({ * Used by buildExecutionPlan() to merge into the execution plan's engineSettings. */ agentEngineSettings: z.record(z.string(), EngineSettingsSchema).optional(), + /** + * Per-agent update-channel overrides keyed by agent type. + * Populated from agent_configs.update_channel rows at config load time. + * Absent / NULL means the agent inherits the default channel (`both`). + * Read at runtime via resolveUpdateChannel() in src/config/updateChannel.ts. + */ + agentUpdateChannels: z.record(z.string(), UpdateChannelSchema).optional(), runLinksEnabled: z.boolean().default(false), maxInFlightItems: z.number().int().positive().optional(), snapshotEnabled: z.boolean().optional(), diff --git a/src/db/migrations/0055_agent_config_update_channel.sql b/src/db/migrations/0055_agent_config_update_channel.sql new file mode 100644 index 000000000..42ed4df12 --- /dev/null +++ b/src/db/migrations/0055_agent_config_update_channel.sql @@ -0,0 +1,6 @@ +-- Add update_channel TEXT column to agent_configs table. +-- NULL means inherit the default update channel (`both`) — the agent posts +-- communication-only status updates to both PM and SCM surfaces (historical +-- behavior). See src/config/updateChannel.ts for the channel catalog/semantics. + +ALTER TABLE "agent_configs" ADD COLUMN IF NOT EXISTS "update_channel" TEXT; diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 05d220c90..6659f6d00 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -386,6 +386,13 @@ "when": 1789000000000, "tag": "0054_org_memberships_backfill", "breakpoints": false + }, + { + "idx": 55, + "version": "7", + "when": 1790000000000, + "tag": "0055_agent_config_update_channel", + "breakpoints": false } ] } diff --git a/src/db/repositories/configMapper.ts b/src/db/repositories/configMapper.ts index 90be617a4..d6079ae09 100644 --- a/src/db/repositories/configMapper.ts +++ b/src/db/repositories/configMapper.ts @@ -1,4 +1,5 @@ import type { EngineSettings } from '../../config/engineSettings.js'; +import { UPDATE_CHANNELS, type UpdateChannel } from '../../config/updateChannel.js'; /** * Config mapper — pure transformation functions for converting DB rows into @@ -56,6 +57,8 @@ export interface AgentConfigRow { maxIterations: number | null; agentEngine: string | null; agentEngineSettings?: EngineSettings | null; + /** Per-agent update-channel override. NULL/absent → inherit the default (`both`). */ + updateChannel?: string | null; } export interface IntegrationRow { @@ -100,6 +103,8 @@ export interface ProjectConfigRaw { engineSettings?: EngineSettings; /** Per-agent engine settings overrides keyed by agent type. */ agentEngineSettings?: Record; + /** Per-agent update-channel overrides keyed by agent type. */ + agentUpdateChannels?: Record; runLinksEnabled?: boolean; maxInFlightItems?: number; snapshotEnabled?: boolean; @@ -167,18 +172,30 @@ export function buildAgentMaps(configs: AgentConfigRow[]): { iterations: Record; engines: Record; engineSettings: Record; + updateChannels: Record; } { const models: Record = {}; const iterations: Record = {}; const engines: Record = {}; const engineSettings: Record = {}; + const updateChannels: Record = {}; for (const ac of configs) { if (ac.model) models[ac.agentType] = ac.model; if (ac.maxIterations != null) iterations[ac.agentType] = ac.maxIterations; if (ac.agentEngine) engines[ac.agentType] = ac.agentEngine; if (ac.agentEngineSettings != null) engineSettings[ac.agentType] = ac.agentEngineSettings; + // Validate the persisted value against the channel catalog; ignore unknown + // values (and NULL) so a stale/invalid column never breaks config loading. + if (ac.updateChannel != null && isUpdateChannel(ac.updateChannel)) { + updateChannels[ac.agentType] = ac.updateChannel; + } } - return { models, iterations, engines, engineSettings }; + return { models, iterations, engines, engineSettings, updateChannels }; +} + +/** Type guard narrowing a persisted string to a known {@link UpdateChannel}. */ +function isUpdateChannel(value: string): value is UpdateChannel { + return (UPDATE_CHANNELS as readonly string[]).includes(value); } export function orUndefined>(obj: T): T | undefined { @@ -290,6 +307,7 @@ export function mapProjectRow({ models, engines, engineSettings: agentEngineSettingsMap, + updateChannels, } = buildAgentMaps(projectAgentConfigs); // Derive PM type from integration config. No PM integration → `undefined` @@ -309,6 +327,7 @@ export function mapProjectRow({ agentEngineSettings: orUndefined(agentEngineSettingsMap) as | Record | undefined, + agentUpdateChannels: orUndefined(updateChannels), }; if (trelloConfig) { diff --git a/src/db/schema/agentConfigs.ts b/src/db/schema/agentConfigs.ts index a86e88fd3..c5310625e 100644 --- a/src/db/schema/agentConfigs.ts +++ b/src/db/schema/agentConfigs.ts @@ -18,6 +18,9 @@ export const agentConfigs = pgTable( maxConcurrency: integer('max_concurrency'), systemPrompt: text('system_prompt'), taskPrompt: text('task_prompt'), + // Per-agent update-channel override. NULL → inherit the default (`both`). + // Validated against UPDATE_CHANNELS in the config mapper. See src/config/updateChannel.ts. + updateChannel: text('update_channel'), createdAt: timestamp('created_at').defaultNow(), updatedAt: timestamp('updated_at') .defaultNow() diff --git a/tests/integration/db/configRepository.test.ts b/tests/integration/db/configRepository.test.ts index bc256f8b5..d0b02e366 100644 --- a/tests/integration/db/configRepository.test.ts +++ b/tests/integration/db/configRepository.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { CascadeConfigSchema, validateConfig } from '../../../src/config/schema.js'; +import { resolveUpdateChannel } from '../../../src/config/updateChannel.js'; import { findProjectByBoardIdFromDb, findProjectByIdFromDb, @@ -62,6 +63,68 @@ describe('configRepository (integration)', () => { expect(project.agentModels?.implementation).toBe('project-impl-model'); }); + // ===================================================================== + // agentUpdateChannels round-trip (MNG-1682) + // ===================================================================== + + it('round-trips every update_channel value into project.agentUpdateChannels', async () => { + await seedAgentConfig({ agentType: 'none-agent', updateChannel: 'none' }); + await seedAgentConfig({ agentType: 'scm-agent', updateChannel: 'scm-only' }); + await seedAgentConfig({ agentType: 'pm-agent', updateChannel: 'pm-only' }); + await seedAgentConfig({ agentType: 'both-agent', updateChannel: 'both' }); + + const config = await loadConfigFromDb(); + const project = config.projects[0]; + + expect(project.agentUpdateChannels).toEqual({ + 'none-agent': 'none', + 'scm-agent': 'scm-only', + 'pm-agent': 'pm-only', + 'both-agent': 'both', + }); + }); + + it('resolves a NULL update_channel to the default (both)', async () => { + // Seeded without an explicit channel → NULL column → inherit the default. + await seedAgentConfig({ agentType: 'implementation', updateChannel: null }); + + const config = await loadConfigFromDb(); + const project = config.projects[0]; + + // NULL is never materialized into the map ... + expect(project.agentUpdateChannels?.implementation).toBeUndefined(); + // ... and the runtime resolver falls back to `both`. + expect(resolveUpdateChannel(project, 'implementation')).toBe('both'); + }); + + it('omits agentUpdateChannels entirely when no agent sets a channel', async () => { + // A row with other overrides but a NULL channel must not create the map. + await seedAgentConfig({ + agentType: 'implementation', + model: 'some-model', + updateChannel: null, + }); + + const config = await loadConfigFromDb(); + const project = config.projects[0]; + + expect(project.agentUpdateChannels).toBeUndefined(); + expect(resolveUpdateChannel(project, 'implementation')).toBe('both'); + }); + + it('ignores an unknown persisted update_channel value and still validates', async () => { + // TEXT column accepts arbitrary strings; the mapper drops unknowns so a + // stale value never trips UpdateChannelSchema during validateConfig(). + await seedAgentConfig({ agentType: 'implementation', updateChannel: 'all' }); + await seedAgentConfig({ agentType: 'review', updateChannel: 'pm-only' }); + + const config = await loadConfigFromDb(); + const project = config.projects[0]; + + expect(project.agentUpdateChannels).toEqual({ review: 'pm-only' }); + expect(resolveUpdateChannel(project, 'implementation')).toBe('both'); + }); + it('handles multiple projects', async () => { await seedProject({ id: 'project-2', name: 'Project 2', repo: 'owner/repo2' }); const config = await loadConfigFromDb(); diff --git a/tests/integration/helpers/seed.ts b/tests/integration/helpers/seed.ts index aaaaa5d90..be31e36e9 100644 --- a/tests/integration/helpers/seed.ts +++ b/tests/integration/helpers/seed.ts @@ -131,6 +131,7 @@ export async function seedAgentConfig( model?: string | null; maxIterations?: number | null; agentEngine?: string | null; + updateChannel?: string | null; } = {}, ) { const db = getDb(); @@ -142,6 +143,7 @@ export async function seedAgentConfig( model: overrides.model ?? null, maxIterations: overrides.maxIterations ?? null, agentEngine: overrides.agentEngine ?? null, + updateChannel: overrides.updateChannel ?? null, }) .returning(); return row; diff --git a/tests/unit/db/repositories/configMapper.test.ts b/tests/unit/db/repositories/configMapper.test.ts index 4216626a3..000736315 100644 --- a/tests/unit/db/repositories/configMapper.test.ts +++ b/tests/unit/db/repositories/configMapper.test.ts @@ -197,6 +197,122 @@ describe('buildAgentMaps', () => { const result = buildAgentMaps(configs); expect(Object.keys(result.engineSettings)).toHaveLength(0); }); + + it('returns empty updateChannels map for empty input', () => { + const result = buildAgentMaps([]); + expect(result.updateChannels).toEqual({}); + }); + + it('maps a valid updateChannel per agent type', () => { + const configs: AgentConfigRow[] = [ + { + projectId: 'proj1', + agentType: 'implementation', + model: null, + maxIterations: null, + agentEngine: null, + updateChannel: 'pm-only', + }, + { + projectId: 'proj1', + agentType: 'review', + model: null, + maxIterations: null, + agentEngine: null, + updateChannel: 'none', + }, + ]; + + const result = buildAgentMaps(configs); + expect(result.updateChannels).toEqual({ implementation: 'pm-only', review: 'none' }); + }); + + it('accepts every known update channel value', () => { + const configs: AgentConfigRow[] = [ + { + projectId: 'proj1', + agentType: 'none-agent', + model: null, + maxIterations: null, + agentEngine: null, + updateChannel: 'none', + }, + { + projectId: 'proj1', + agentType: 'scm-agent', + model: null, + maxIterations: null, + agentEngine: null, + updateChannel: 'scm-only', + }, + { + projectId: 'proj1', + agentType: 'pm-agent', + model: null, + maxIterations: null, + agentEngine: null, + updateChannel: 'pm-only', + }, + { + projectId: 'proj1', + agentType: 'both-agent', + model: null, + maxIterations: null, + agentEngine: null, + updateChannel: 'both', + }, + ]; + + const result = buildAgentMaps(configs); + expect(result.updateChannels).toEqual({ + 'none-agent': 'none', + 'scm-agent': 'scm-only', + 'pm-agent': 'pm-only', + 'both-agent': 'both', + }); + }); + + it('ignores null updateChannel values', () => { + const configs: AgentConfigRow[] = [ + { + projectId: 'proj1', + agentType: 'implementation', + model: null, + maxIterations: null, + agentEngine: null, + updateChannel: null, + }, + ]; + + const result = buildAgentMaps(configs); + expect(Object.keys(result.updateChannels)).toHaveLength(0); + }); + + it('ignores unknown updateChannel values (validated against UPDATE_CHANNELS)', () => { + const configs: AgentConfigRow[] = [ + { + projectId: 'proj1', + agentType: 'implementation', + model: null, + maxIterations: null, + agentEngine: null, + // not in UPDATE_CHANNELS — must be ignored, not cast through + updateChannel: 'all', + }, + { + projectId: 'proj1', + agentType: 'review', + model: null, + maxIterations: null, + agentEngine: null, + updateChannel: 'both', + }, + ]; + + const result = buildAgentMaps(configs); + expect(result.updateChannels).toEqual({ review: 'both' }); + expect(result.updateChannels.implementation).toBeUndefined(); + }); }); // --------------------------------------------------------------------------- @@ -431,4 +547,42 @@ describe('mapProjectRow', () => { const result = mapProjectRow(makeInput({ row: { ...baseProjectRow, snapshotTtlMs: 3600000 } })); expect(result.snapshotTtlMs).toBe(3600000); }); + + it('builds agentUpdateChannels from project agent configs', () => { + const agentConfigs: AgentConfigRow[] = [ + { + projectId: 'proj1', + agentType: 'implementation', + model: null, + maxIterations: null, + agentEngine: null, + updateChannel: 'pm-only', + }, + { + projectId: 'proj1', + agentType: 'review', + model: null, + maxIterations: null, + agentEngine: null, + updateChannel: 'none', + }, + ]; + const result = mapProjectRow(makeInput({ projectAgentConfigs: agentConfigs })); + expect(result.agentUpdateChannels).toEqual({ implementation: 'pm-only', review: 'none' }); + }); + + it('leaves agentUpdateChannels undefined when no channels are configured (orUndefined)', () => { + const agentConfigs: AgentConfigRow[] = [ + { + projectId: 'proj1', + agentType: 'implementation', + model: 'some-model', + maxIterations: null, + agentEngine: null, + updateChannel: null, + }, + ]; + const result = mapProjectRow(makeInput({ projectAgentConfigs: agentConfigs })); + expect(result.agentUpdateChannels).toBeUndefined(); + }); }); From bf6100d5533c40de7b04eb6b6aa05aaa6509eb3f Mon Sep 17 00:00:00 2001 From: aaight Date: Thu, 25 Jun 2026 11:42:56 +0200 Subject: [PATCH 13/18] feat(backends): gate agent posting tools per update channel for both engine families (#1446) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the communication-only PM/SCM posting tools an agent is allowed to call based on the resolved per-agent update channel, so an agent literally cannot post on a disabled channel — across both engine families. - buildExecutionPlan (native-tool family: claude-code/codex/opencode): after profile.filterTools(...), apply filterPostingGadgetNames over the resolved channel to drop posting tool manifests from availableTools (the system-prompt tool list). - getLlmistGadgets result (llmist family): apply the same filter to the gadget instances passed to createConfiguredBuilder. Layers on top of the existing integration-availability filtering: an enabled channel against an absent tool simply has nothing to drop. MNG-1685 Co-authored-by: Cascade Bot Co-authored-by: Claude Opus 4.8 --- src/backends/llmist/index.ts | 17 ++- src/backends/secretOrchestrator.ts | 20 ++- tests/unit/backends/llmist.test.ts | 129 ++++++++++++++++++ .../unit/backends/secretOrchestrator.test.ts | 118 ++++++++++++++++ 4 files changed, 282 insertions(+), 2 deletions(-) diff --git a/src/backends/llmist/index.ts b/src/backends/llmist/index.ts index 36ba8dbb0..da2b09c68 100644 --- a/src/backends/llmist/index.ts +++ b/src/backends/llmist/index.ts @@ -12,6 +12,7 @@ import { getLogLevel } from '../../agents/utils/index.js'; import { createAgentLogger } from '../../agents/utils/logging.js'; import { createTrackingContext } from '../../agents/utils/tracking.js'; import { CUSTOM_MODELS } from '../../config/customModels.js'; +import { filterPostingGadgetNames, resolveUpdateChannel } from '../../config/updateChannel.js'; import { getSessionState } from '../../gadgets/sessionState.js'; import { createLLMCallLogger } from '../../utils/llmLogging.js'; import { LLMIST_ENGINE_DEFINITION } from '../catalog.js'; @@ -87,7 +88,21 @@ export class LlmistEngine implements AgentEngine { // Get gadget instances from the agent profile, filtered by integration availability. // This ensures optional capabilities only provide gadgets if the integration is configured. const integrationChecker = await createIntegrationChecker(input.project.id); - const gadgets = profile.getLlmistGadgets(integrationChecker); + const allGadgets = profile.getLlmistGadgets(integrationChecker) as Array<{ name: string }>; + + // Drop the communication-only PM/SCM posting gadgets the resolved update + // channel disables, mirroring the native-tool path in buildExecutionPlan() so + // an agent cannot post on a disabled channel via either engine family. Layers + // on top of getLlmistGadgets' integration-availability filtering: an enabled + // channel against an absent gadget simply has nothing to drop. + const updateChannel = resolveUpdateChannel(input.project, agentType); + const allowedGadgetNames = new Set( + filterPostingGadgetNames( + allGadgets.map((gadget) => gadget.name), + updateChannel, + ), + ); + const gadgets = allGadgets.filter((gadget) => allowedGadgetNames.has(gadget.name)); // Build the configured agent builder with all llmist-specific features: // rate limiting, retry, compaction, iteration hints, observer hooks diff --git a/src/backends/secretOrchestrator.ts b/src/backends/secretOrchestrator.ts index 8b97be5d7..444bc34b5 100644 --- a/src/backends/secretOrchestrator.ts +++ b/src/backends/secretOrchestrator.ts @@ -12,6 +12,7 @@ import { resolveModelConfig } from '../agents/shared/modelResolution.js'; import { buildPromptContext } from '../agents/shared/promptContext.js'; import type { createAgentLogger } from '../agents/utils/logging.js'; import { mergeEngineSettings } from '../config/engineSettings.js'; +import { filterPostingGadgetNames, resolveUpdateChannel } from '../config/updateChannel.js'; import { loadPartials } from '../db/repositories/partialsRepository.js'; import { withGitHubToken } from '../github/client.js'; import { getSentryIntegrationConfig } from '../sentry/integration.js'; @@ -186,6 +187,23 @@ export async function buildExecutionPlan( agentLevelEngineSettings, ); + // Resolve the per-agent update channel and drop the communication-only PM/SCM + // posting tool manifests it disables, so an agent literally cannot call a + // disabled-channel comment/review tool. This is the authoritative gate for the + // native-tool engine family (claude-code/codex/opencode), which renders + // availableTools into the system-prompt tool list. It layers on top of the + // integration-availability filtering already done by profile.filterTools(): + // an enabled channel against an absent tool simply has nothing to drop. + const availableTools = profile.filterTools(getToolManifests(), integrationChecker); + const updateChannel = resolveUpdateChannel(project, agentType); + const allowedToolNames = new Set( + filterPostingGadgetNames( + availableTools.map((tool) => tool.name), + updateChannel, + ), + ); + const channelFilteredTools = availableTools.filter((tool) => allowedToolNames.has(tool.name)); + return { agentType, project, @@ -195,7 +213,7 @@ export async function buildExecutionPlan( taskPrompt: taskPromptOverride ?? profile.buildTaskPrompt(input), cliToolsDir, nativeToolShimDir: nativeToolRuntime?.shimDir, - availableTools: profile.filterTools(getToolManifests(), integrationChecker), + availableTools: channelFilteredTools, contextInjections, maxIterations, budgetUsd: input.remainingBudgetUsd as number | undefined, diff --git a/tests/unit/backends/llmist.test.ts b/tests/unit/backends/llmist.test.ts index 7b6c0de67..3aacf0480 100644 --- a/tests/unit/backends/llmist.test.ts +++ b/tests/unit/backends/llmist.test.ts @@ -94,9 +94,12 @@ vi.mock('../../../src/gadgets/sessionState.js', async (importOriginal) => { }; }); +import { getAgentProfile } from '../../../src/agents/definitions/profiles.js'; +import { createConfiguredBuilder } from '../../../src/agents/shared/builderFactory.js'; import { runAgentLoop } from '../../../src/agents/utils/agentLoop.js'; import { LlmistEngine } from '../../../src/backends/llmist/index.js'; import type { AgentExecutionPlan } from '../../../src/backends/types.js'; +import type { UpdateChannel } from '../../../src/config/updateChannel.js'; import { getSessionState } from '../../../src/gadgets/sessionState.js'; const mockRunAgentLoop = vi.mocked(runAgentLoop); @@ -429,3 +432,129 @@ describe('LlmistEngine.execute', () => { ); }); }); + +// MNG-1685: the llmist engine family gates posting at the gadget-instance level — +// the gadgets passed to createConfiguredBuilder are the only tools the agent can +// call, so dropping the disabled posting gadgets here is the authoritative gate. +describe('LlmistEngine.execute — update channel posting-gadget gating', () => { + const PM_POSTING = ['PostComment']; + const SCM_POSTING = [ + 'PostPRComment', + 'UpdatePRComment', + 'CreatePRReview', + 'ReplyToReviewComment', + ]; + // Action / read gadgets (incl. SCM read GetPRComments) that must NEVER be dropped. + const NON_POSTING = ['ReadFile', 'MoveWorkItem', 'CreatePR', 'GetPRComments']; + const ALL_GADGETS = [...NON_POSTING, ...PM_POSTING, ...SCM_POSTING].map((name) => ({ name })); + + const mockGetAgentProfile = vi.mocked(getAgentProfile); + const mockCreateConfiguredBuilder = vi.mocked(createConfiguredBuilder); + + beforeEach(() => { + mockGetSessionState.mockReturnValue({ prUrl: null } as ReturnType); + mockRunAgentLoop.mockResolvedValue({ + output: 'Done', + iterations: 1, + gadgetCalls: 0, + cost: 0.01, + loopTerminated: false, + }); + }); + + async function gadgetNamesPassedToBuilder( + agentType: string, + channel: UpdateChannel | undefined, + gadgets: Array<{ name: string }> = ALL_GADGETS, + ): Promise { + mockGetAgentProfile.mockReturnValue({ + getLlmistGadgets: vi.fn(() => gadgets), + finishHooks: {}, + } as unknown as ReturnType); + + const input = makeInput(agentType); + if (channel) { + input.project = { + ...input.project, + agentUpdateChannels: { [agentType]: channel }, + } as AgentExecutionPlan['project']; + } + + await new LlmistEngine().execute(input); + + const lastCall = mockCreateConfiguredBuilder.mock.calls.at(-1); + const passed = (lastCall?.[0].gadgets ?? []) as Array<{ name: string }>; + return passed.map((gadget) => gadget.name); + } + + it("'both' retains every posting gadget plus action gadgets", async () => { + expect(await gadgetNamesPassedToBuilder('implementation', 'both')).toEqual([ + ...NON_POSTING, + ...PM_POSTING, + ...SCM_POSTING, + ]); + }); + + it("'none' drops all PM and SCM posting gadgets, retaining everything else", async () => { + const names = await gadgetNamesPassedToBuilder('implementation', 'none'); + expect(names).toEqual(NON_POSTING); + for (const dropped of [...PM_POSTING, ...SCM_POSTING]) { + expect(names).not.toContain(dropped); + } + }); + + it("'pm-only' retains PM posting and drops SCM posting", async () => { + expect(await gadgetNamesPassedToBuilder('implementation', 'pm-only')).toEqual([ + ...NON_POSTING, + ...PM_POSTING, + ]); + }); + + it("'scm-only' retains SCM posting and drops PM posting", async () => { + expect(await gadgetNamesPassedToBuilder('implementation', 'scm-only')).toEqual([ + ...NON_POSTING, + ...SCM_POSTING, + ]); + }); + + it("defaults to 'both' (retains all posting gadgets) when no channel is configured", async () => { + expect(await gadgetNamesPassedToBuilder('implementation', undefined)).toEqual([ + ...NON_POSTING, + ...PM_POSTING, + ...SCM_POSTING, + ]); + }); + + it('no-ops gracefully when a disabled posting gadget is already absent (layers on integration filtering)', async () => { + // getLlmistGadgets already omitted PostComment + SCM posting (integration not + // configured). A channel that disables them has nothing to drop. + const names = await gadgetNamesPassedToBuilder('implementation', 'none', [ + { name: 'ReadFile' }, + { name: 'CreatePR' }, + ]); + expect(names).toEqual(['ReadFile', 'CreatePR']); + }); + + it('resolves the channel for the agent type being executed', async () => { + // review is disabled but implementation is 'both' — executing 'implementation' + // must read the implementation entry, not review's. + mockGetAgentProfile.mockReturnValue({ + getLlmistGadgets: vi.fn(() => ALL_GADGETS), + finishHooks: {}, + } as unknown as ReturnType); + + const input = makeInput('implementation'); + input.project = { + ...input.project, + agentUpdateChannels: { review: 'none', implementation: 'both' }, + } as AgentExecutionPlan['project']; + + await new LlmistEngine().execute(input); + + const lastCall = mockCreateConfiguredBuilder.mock.calls.at(-1); + const names = ((lastCall?.[0].gadgets ?? []) as Array<{ name: string }>).map( + (gadget) => gadget.name, + ); + expect(names).toEqual([...NON_POSTING, ...PM_POSTING, ...SCM_POSTING]); + }); +}); diff --git a/tests/unit/backends/secretOrchestrator.test.ts b/tests/unit/backends/secretOrchestrator.test.ts index 5f40e1953..11badb634 100644 --- a/tests/unit/backends/secretOrchestrator.test.ts +++ b/tests/unit/backends/secretOrchestrator.test.ts @@ -74,6 +74,7 @@ import { injectRunLinkSecrets, } from '../../../src/backends/secretOrchestrator.js'; import type { AgentEngine } from '../../../src/backends/types.js'; +import type { UpdateChannel } from '../../../src/config/updateChannel.js'; import { getSentryIntegrationConfig } from '../../../src/sentry/integration.js'; import type { AgentInput, CascadeConfig, ProjectConfig } from '../../../src/types/index.js'; import { getDashboardUrl } from '../../../src/utils/runLink.js'; @@ -299,6 +300,123 @@ describe('buildExecutionPlan', () => { expect(withoutFriction.systemPrompt).not.toContain('Friction Reporting'); }); + + // MNG-1685: the native-tool engine family (claude-code/codex/opencode) renders + // plan.availableTools into the system-prompt tool list, so dropping the disabled + // posting tool manifests here is the authoritative gate for that family. + describe('update channel posting-tool gating', () => { + const PM_POSTING = ['PostComment']; + const SCM_POSTING = [ + 'PostPRComment', + 'UpdatePRComment', + 'CreatePRReview', + 'ReplyToReviewComment', + ]; + // Action / read tools that must NEVER be dropped by channel gating. + const NON_POSTING = ['ReadWorkItem', 'UpdateWorkItem', 'MoveWorkItem', 'CreatePR', 'ReadFile']; + const ALL_TOOLS = [...NON_POSTING, ...PM_POSTING, ...SCM_POSTING].map((name) => ({ name })); + + function mockProfileWithTools(tools: Array<{ name: string }>) { + mockGetAgentProfile.mockReturnValueOnce({ + fetchContext: vi.fn().mockResolvedValue([]), + finishHooks: {}, + lifecycleHooks: {}, + filterTools: vi.fn().mockReturnValue(tools), + allCapabilities: ['fs:read'], + needsGitHubToken: false, + buildTaskPrompt: () => 'task', + capabilities: { required: ['fs:read'], optional: [] }, + getLlmistGadgets: vi.fn(), + }); + } + + async function availableToolNamesFor( + channel: UpdateChannel | undefined, + tools: Array<{ name: string }> = ALL_TOOLS, + ): Promise { + mockProfileWithTools(tools); + const project = makeProject( + channel ? { agentUpdateChannels: { review: channel } } : undefined, + ); + const plan = await buildExecutionPlan( + 'review', + makeInput(project, 'manual'), + '/repo', + noopLogWriter, + noopAgentLogger, + undefined, + false, + 'claude-code', + engine, + ); + return (plan.availableTools as Array<{ name: string }>).map((tool) => tool.name); + } + + it("'both' retains every posting tool plus action tools", async () => { + expect(await availableToolNamesFor('both')).toEqual([ + ...NON_POSTING, + ...PM_POSTING, + ...SCM_POSTING, + ]); + }); + + it("'none' drops all PM and SCM posting tools, retaining everything else", async () => { + const names = await availableToolNamesFor('none'); + expect(names).toEqual(NON_POSTING); + for (const dropped of [...PM_POSTING, ...SCM_POSTING]) { + expect(names).not.toContain(dropped); + } + }); + + it("'pm-only' retains PM posting and drops SCM posting", async () => { + expect(await availableToolNamesFor('pm-only')).toEqual([...NON_POSTING, ...PM_POSTING]); + }); + + it("'scm-only' retains SCM posting and drops PM posting", async () => { + expect(await availableToolNamesFor('scm-only')).toEqual([...NON_POSTING, ...SCM_POSTING]); + }); + + it("defaults to 'both' (retains all posting tools) when no channel is configured", async () => { + expect(await availableToolNamesFor(undefined)).toEqual([ + ...NON_POSTING, + ...PM_POSTING, + ...SCM_POSTING, + ]); + }); + + it('no-ops gracefully when a disabled posting tool is already absent (layers on integration filtering)', async () => { + // Integration-availability filtering already removed PostComment + SCM + // posting (PM/SCM integration not configured). A channel that disables them + // has nothing to drop and must neither error nor resurrect them. + const names = await availableToolNamesFor('none', [ + { name: 'ReadFile' }, + { name: 'CreatePR' }, + ]); + expect(names).toEqual(['ReadFile', 'CreatePR']); + }); + + it('resolves the channel for the agent type being built', async () => { + // review is disabled but implementation is 'both' — building 'implementation' + // must read the implementation entry, not review's. + mockProfileWithTools(ALL_TOOLS); + const project = makeProject({ + agentUpdateChannels: { review: 'none', implementation: 'both' }, + }); + const plan = await buildExecutionPlan( + 'implementation', + makeInput(project, 'manual'), + '/repo', + noopLogWriter, + noopAgentLogger, + undefined, + false, + 'claude-code', + engine, + ); + const names = (plan.availableTools as Array<{ name: string }>).map((tool) => tool.name); + expect(names).toEqual([...NON_POSTING, ...PM_POSTING, ...SCM_POSTING]); + }); + }); }); describe('injectRunLinkSecrets', () => { From e5f510eab0f6be488e2bf0e61063cfe3a148fc95 Mon Sep 17 00:00:00 2001 From: aaight Date: Thu, 25 Jun 2026 13:34:58 +0200 Subject: [PATCH 14/18] feat(posting): gate system-driven PM/SCM posts by agent update channel (#1447) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(posting): gate system-driven PM/SCM posts by agent update channel Layer the resolved update channel onto every system-driven (non-agent) communication surface so acks, progress comments, lifecycle comments, and summaries respect the per-agent channel. Communication only — status moves, label add/remove, linkPR, and PR creation stay untouched. - buildProgressMonitorConfig: omit `trello` (PM) when PM posting is disabled and `github` (SCM) when SCM posting is disabled (ProgressMonitor already no-ops an absent poster block). - PMLifecycleManager: new `pmPostingEnabled = true` constructor flag that short-circuits the comment helpers (success-fallback, failure, budget exceeded/warning, error); constructed with the resolved flag in createAgentExecutionContext. - postAgentSummaryToPM: early-returns when PM posting is disabled (project threaded in to resolve the channel). - Router adapters: each postAck skips the PM ack when PM posting is disabled; the GitHub adapter additionally skips the PR ack when SCM posting is disabled and gates the PM-focused-agent ack branch on PM posting (postAck refactored into postPMFocusedAgentAck + postRegularPRAck helpers). Tests cover progress-config omission, lifecycle comment suppression vs move/label/linkPR retention, summary early-return, and per-channel adapter postAck gating. Co-Authored-By: Claude Opus 4.8 * fix(posting): gate worker-side last-resort error comment by update channel The operational-fault catch path in processPMWebhook constructed PMLifecycleManager with the default pmPostingEnabled=true, so an agent whose update channel disables PM posting (none / scm-only) would still post a `❌ Error:` comment to the PM card on an unhandled exception escaping executeAgent. Resolve the flag from the agent's update channel (mirroring createAgentExecutionContext) so the "disabled PM channel simply no-ops" invariant also holds on the error path. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Cascade Bot Co-authored-by: Claude Opus 4.8 --- src/backends/progressLifecycle.ts | 18 +- src/pm/lifecycle.ts | 15 ++ src/pm/webhook-handler.ts | 13 +- src/router/adapters/github.ts | 166 +++++++++++++----- src/router/adapters/jira.ts | 18 +- src/router/adapters/linear.ts | 18 +- src/router/adapters/trello.ts | 18 +- .../shared/agent-execution-runtime.ts | 10 +- src/triggers/shared/agent-pm-summary.ts | 19 +- tests/unit/backends/progressLifecycle.test.ts | 92 ++++++++++ tests/unit/pm/lifecycle.test.ts | 102 +++++++++++ tests/unit/pm/webhook-handler.test.ts | 45 +++++ tests/unit/router/adapters/github.test.ts | 96 ++++++++++ tests/unit/router/adapters/jira.test.ts | 26 +++ tests/unit/router/adapters/linear.test.ts | 26 +++ tests/unit/router/adapters/trello.test.ts | 47 +++++ .../triggers/shared/agent-execution.test.ts | 50 +++++- .../triggers/shared/agent-pm-summary.test.ts | 86 ++++++++- 18 files changed, 800 insertions(+), 65 deletions(-) diff --git a/src/backends/progressLifecycle.ts b/src/backends/progressLifecycle.ts index 5a261d5ae..968111e3a 100644 --- a/src/backends/progressLifecycle.ts +++ b/src/backends/progressLifecycle.ts @@ -2,6 +2,11 @@ import type { ModelSpec } from 'llmist'; import type { LogWriter } from '../agents/shared/executionPipeline.js'; import { CUSTOM_MODELS } from '../config/customModels.js'; +import { + isPmPostingEnabled, + isScmPostingEnabled, + resolveUpdateChannel, +} from '../config/updateChannel.js'; import type { AgentInput, CascadeConfig, ProjectConfig } from '../types/index.js'; import { getDashboardUrl } from '../utils/runLink.js'; @@ -28,6 +33,15 @@ export function buildProgressMonitorConfig( ) { const { workItemId } = input; + // Gate system-driven progress posting on the agent's resolved update channel. + // ProgressMonitor already no-ops each poster when its config block is absent, + // so gating = omitting `trello` (PM) / `github` (SCM) when the channel + // disables that surface. Communication only — status moves, labels, linkPR, + // and PR creation are gated elsewhere (or not at all). + const updateChannel = resolveUpdateChannel(input.project, agentType); + const pmPostingEnabled = isPmPostingEnabled(updateChannel); + const scmPostingEnabled = isScmPostingEnabled(updateChannel); + // Build run link config when the project has run links enabled and dashboard URL is set const runLink = input.project.runLinksEnabled && getDashboardUrl() @@ -47,11 +61,11 @@ export function buildProgressMonitorConfig( intervalMinutes: input.project.progressIntervalMinutes, customModels: CUSTOM_MODELS as ModelSpec[], repoDir: repoDir ?? undefined, - trello: workItemId ? { workItemId } : undefined, + trello: pmPostingEnabled && workItemId ? { workItemId } : undefined, preSeededCommentId: isGitHubAck ? undefined : (input.ackCommentId as string | undefined), runLink, syncChecklist, - ...(input.prNumber && input.repoFullName + ...(scmPostingEnabled && input.prNumber && input.repoFullName ? { github: { owner: input.repoFullName.split('/')[0], diff --git a/src/pm/lifecycle.ts b/src/pm/lifecycle.ts index 5f6fe9ac9..ddc5f4fa1 100644 --- a/src/pm/lifecycle.ts +++ b/src/pm/lifecycle.ts @@ -67,9 +67,18 @@ export function resolveProjectPMConfig(project: ProjectConfig): ProjectPMConfig } export class PMLifecycleManager { + /** + * @param pmPostingEnabled When `false`, the manager suppresses every + * communication-only comment post (success-fallback "PR created", failure, + * budget exceeded/warning, error) while leaving status moves, label + * add/remove, and `linkPR` untouched. Resolved from the agent's update + * channel by `createAgentExecutionContext`. Defaults to `true` so existing + * call sites (and tests) keep posting everywhere. + */ constructor( private provider: PMProvider, private pmConfig: ProjectPMConfig, + private pmPostingEnabled = true, ) {} async prepareForAgent(workItemId: string, hooks: LifecycleHooks): Promise { @@ -182,6 +191,9 @@ export class PMLifecycleManager { } private async safeAddComment(workItemId: string, text: string): Promise { + // Communication-only post — suppressed when the agent's update channel + // disables PM posting. Labels / moves / linkPR do not pass through here. + if (!this.pmPostingEnabled) return; await safeOperation(() => this.provider.addComment(workItemId, text), { action: 'add comment', }); @@ -197,6 +209,9 @@ export class PMLifecycleManager { commentId: string, text: string, ): Promise { + // Communication-only post — suppressed when the agent's update channel + // disables PM posting (mirrors safeAddComment). + if (!this.pmPostingEnabled) return; try { await this.provider.updateComment(workItemId, commentId, text); } catch { diff --git a/src/pm/webhook-handler.ts b/src/pm/webhook-handler.ts index 770af3929..6a261dd31 100644 --- a/src/pm/webhook-handler.ts +++ b/src/pm/webhook-handler.ts @@ -8,6 +8,7 @@ */ import { loadProjectConfigById } from '../config/provider.js'; +import { isPmPostingEnabled, resolveUpdateChannel } from '../config/updateChannel.js'; import type { TriggerRegistry } from '../triggers/registry.js'; import { withAgentTypeConcurrency } from '../triggers/shared/concurrency.js'; import { resolveTriggerResult } from '../triggers/shared/trigger-resolution.js'; @@ -109,7 +110,17 @@ async function handleMatchedTrigger( startWatchdog(project.watchdogTimeoutMs); const pmConfig = resolveProjectPMConfig(project); - const lifecycle = new PMLifecycleManager(getPMProvider(), pmConfig); + // Gate the last-resort error comment on the agent's resolved update + // channel, mirroring the gated inner lifecycle built in + // `createAgentExecutionContext`. This is the only lifecycle constructed on + // the operational-fault catch path (an unhandled exception escaping + // `executeAgent`); without this flag a `none` / `scm-only` agent would + // still post a `❌ Error:` comment to the PM card, the one place the + // "disabled PM channel simply no-ops" invariant otherwise wouldn't hold. + const pmPostingEnabled = result.agentType + ? isPmPostingEnabled(resolveUpdateChannel(project, result.agentType)) + : true; + const lifecycle = new PMLifecycleManager(getPMProvider(), pmConfig, pmPostingEnabled); try { await executeAgent(integration, result, project, config); diff --git a/src/router/adapters/github.ts b/src/router/adapters/github.ts index c8adf7595..aa7e5e4f2 100644 --- a/src/router/adapters/github.ts +++ b/src/router/adapters/github.ts @@ -10,6 +10,11 @@ import { isPMFocusedAgent } from '../../agents/definitions/loader.js'; import { getProjectGitHubToken } from '../../config/projects.js'; import { findProjectByRepo } from '../../config/provider.js'; +import { + isPmPostingEnabled, + isScmPostingEnabled, + resolveUpdateChannel, +} from '../../config/updateChannel.js'; import { withGitHubToken } from '../../github/client.js'; import { isCascadeBot, @@ -86,6 +91,24 @@ async function postGitHubPRAck( return undefined; } +/** + * Append the work-item run-link footer to a message when run links are enabled + * for the project and a dashboard URL is configured. Returns the message + * unchanged otherwise. + */ +function appendRunLink( + message: string, + fullProject: ProjectConfig | undefined, + projectId: string, + workItemId: string, +): string { + if (!fullProject?.runLinksEnabled) return message; + const dashboardUrl = getDashboardUrl(); + if (!dashboardUrl) return message; + const link = buildWorkItemRunsLink({ dashboardUrl, projectId, workItemId }); + return link ? message + link : message; +} + export const PROCESSABLE_EVENTS = [ 'pull_request', 'pull_request_review', @@ -307,58 +330,35 @@ export class GitHubRouterAdapter implements RouterPlatformAdapter { const config = await loadProjectConfig(); fullProject = config.fullProjects.find((fp) => fp.id === project.id); } - const runLinksEnabled = fullProject?.runLinksEnabled ?? false; - // Helper to append run link footer to a message when enabled - const withRunLink = (message: string, workItemId?: string): string => { - if (!runLinksEnabled || !workItemId) return message; - const dashboardUrl = getDashboardUrl(); - if (!dashboardUrl) return message; - const link = buildWorkItemRunsLink({ dashboardUrl, projectId: project.id, workItemId }); - return link ? message + link : message; - }; + // Gate system-driven ack posting on the agent's resolved update + // channel. PM-focused acks land on the PM card (PM surface); regular + // acks land on the PR (SCM surface). Absent full project ⇒ default + // channel (post everywhere). Workflow actions are gated elsewhere. + const updateChannel = resolveUpdateChannel(fullProject ?? {}, agentType); // PM-focused agents (e.g. backlog-manager) should have ack posted to the PM tool // (Trello/JIRA card), not to the already-merged GitHub PR. if (await isPMFocusedAgent(agentType)) { - const workItemId = triggerResult?.workItemId ?? event.workItemId; - if (!workItemId) { - logger.warn('PM-focused agent has no workItemId for ack, skipping PM ack', { agentType }); - return undefined; - } - const context = extractGitHubContext(payload, event.eventType); - const baseMessage = await generateAckMessage(agentType, context, project.id); - const message = withRunLink(baseMessage, workItemId); - return postPMAck(project.id, workItemId, project.pmType, agentType, message); - } - - // Build the GitHub PR ack message with run link included before posting, - // so the actual comment on the PR contains the footer (not just internal metadata). - let githubAckMessage: string | undefined; - const workItemIdForLink = triggerResult?.workItemId ?? event.workItemId; - if (runLinksEnabled && workItemIdForLink) { - const dashboardUrl = getDashboardUrl(); - if (dashboardUrl) { - const context = extractGitHubContext(payload, event.eventType); - const baseMessage = await generateAckMessage(agentType, context, project.id); - const link = buildWorkItemRunsLink({ - dashboardUrl, - projectId: project.id, - workItemId: workItemIdForLink, - }); - githubAckMessage = link ? baseMessage + link : baseMessage; - } + return await this.postPMFocusedAgentAck( + event, + payload, + project, + agentType, + triggerResult, + fullProject, + isPmPostingEnabled(updateChannel), + ); } - // Pass cached project to avoid a redundant findProjectByRepo() in the token resolver - return postGitHubPRAck( - (event as GitHubParsedEvent).repoFullName, - event.eventType, + return await this.postRegularPRAck( + event, payload, + project, agentType, - project.id, - githubAckMessage, + triggerResult, fullProject, + isScmPostingEnabled(updateChannel), ); } catch (err) { logger.warn('GitHub ack comment failed (non-fatal)', { error: String(err) }); @@ -366,6 +366,88 @@ export class GitHubRouterAdapter implements RouterPlatformAdapter { return undefined; } + /** + * Post the PM-tool ack for a PM-focused agent (e.g. backlog-manager). + * Communication-only — skipped when the update channel disables PM posting. + */ + private async postPMFocusedAgentAck( + event: ParsedWebhookEvent, + payload: unknown, + project: RouterProjectConfig, + agentType: string, + triggerResult: TriggerResult | undefined, + fullProject: ProjectConfig | undefined, + pmPostingEnabled: boolean, + ): Promise { + if (!pmPostingEnabled) { + logger.info('GitHub PM-focused ack skipped: PM posting disabled for update channel', { + projectId: project.id, + agentType, + }); + return undefined; + } + const workItemId = triggerResult?.workItemId ?? event.workItemId; + if (!workItemId) { + logger.warn('PM-focused agent has no workItemId for ack, skipping PM ack', { agentType }); + return undefined; + } + const context = extractGitHubContext(payload, event.eventType); + const baseMessage = await generateAckMessage(agentType, context, project.id); + const message = appendRunLink(baseMessage, fullProject, project.id, workItemId); + return postPMAck(project.id, workItemId, project.pmType, agentType, message); + } + + /** + * Post the GitHub PR ack for a regular (non-PM-focused) agent. + * Communication-only — skipped when the update channel disables SCM posting. + */ + private async postRegularPRAck( + event: ParsedWebhookEvent, + payload: unknown, + project: RouterProjectConfig, + agentType: string, + triggerResult: TriggerResult | undefined, + fullProject: ProjectConfig | undefined, + scmPostingEnabled: boolean, + ): Promise { + if (!scmPostingEnabled) { + logger.info('GitHub PR ack skipped: SCM posting disabled for update channel', { + projectId: project.id, + agentType, + }); + return undefined; + } + + // Build the GitHub PR ack message with run link included before posting, + // so the actual comment on the PR contains the footer (not just internal metadata). + let githubAckMessage: string | undefined; + const workItemIdForLink = triggerResult?.workItemId ?? event.workItemId; + if (fullProject?.runLinksEnabled && workItemIdForLink) { + const dashboardUrl = getDashboardUrl(); + if (dashboardUrl) { + const context = extractGitHubContext(payload, event.eventType); + const baseMessage = await generateAckMessage(agentType, context, project.id); + const link = buildWorkItemRunsLink({ + dashboardUrl, + projectId: project.id, + workItemId: workItemIdForLink, + }); + githubAckMessage = link ? baseMessage + link : baseMessage; + } + } + + // Pass cached project to avoid a redundant findProjectByRepo() in the token resolver + return postGitHubPRAck( + (event as GitHubParsedEvent).repoFullName, + event.eventType, + payload, + agentType, + project.id, + githubAckMessage, + fullProject, + ); + } + buildJob( event: ParsedWebhookEvent, payload: unknown, diff --git a/src/router/adapters/jira.ts b/src/router/adapters/jira.ts index 91c7eea4f..15ab99c55 100644 --- a/src/router/adapters/jira.ts +++ b/src/router/adapters/jira.ts @@ -7,6 +7,7 @@ * `processRouterWebhook()` function. */ +import { isPmPostingEnabled, resolveUpdateChannel } from '../../config/updateChannel.js'; import { withJiraCredentials } from '../../jira/client.js'; import type { TriggerRegistry } from '../../triggers/registry.js'; import type { TriggerContext, TriggerResult } from '../../types/index.js'; @@ -142,12 +143,25 @@ export class JiraRouterAdapter implements RouterPlatformAdapter { const issueKey = (event as JiraParsedEvent).issueKey; if (!issueKey) return undefined; try { + const config = await loadProjectConfig(); + const fullProject = config.fullProjects.find((fp) => fp.id === project.id); + + // Skip the PM ack when the agent's update channel disables PM posting. + // The ack comment is communication-only; status moves / labels are + // unaffected. Absent full project ⇒ default channel (post everywhere). + if (fullProject && !isPmPostingEnabled(resolveUpdateChannel(fullProject, agentType))) { + logger.info('JIRA ack skipped: PM posting disabled for update channel', { + projectId: project.id, + agentType, + issueKey, + }); + return undefined; + } + const context = extractJiraContext(payload); let message = await generateAckMessage(agentType, context, project.id); // Append run link footer when enabled for this project - const config = await loadProjectConfig(); - const fullProject = config.fullProjects.find((fp) => fp.id === project.id); if (fullProject?.runLinksEnabled && event.workItemId) { const dashboardUrl = getDashboardUrl(); if (dashboardUrl) { diff --git a/src/router/adapters/linear.ts b/src/router/adapters/linear.ts index 6f34d2483..7da146072 100644 --- a/src/router/adapters/linear.ts +++ b/src/router/adapters/linear.ts @@ -7,6 +7,7 @@ * processRouterWebhook() function. */ +import { isPmPostingEnabled, resolveUpdateChannel } from '../../config/updateChannel.js'; import { linearClient, withLinearCredentials } from '../../linear/client.js'; import type { LinearWebhookPayload } from '../../linear/types.js'; import type { TriggerRegistry } from '../../triggers/registry.js'; @@ -287,12 +288,25 @@ export class LinearRouterAdapter implements RouterPlatformAdapter { if (!issueId) return undefined; try { + const config = await loadProjectConfig(); + const fullProject = config.fullProjects.find((fp) => fp.id === project.id); + + // Skip the PM ack when the agent's update channel disables PM posting. + // The ack comment is communication-only; status moves / labels are + // unaffected. Absent full project ⇒ default channel (post everywhere). + if (fullProject && !isPmPostingEnabled(resolveUpdateChannel(fullProject, agentType))) { + logger.info('LinearRouterAdapter: ack skipped, PM posting disabled for update channel', { + projectId: project.id, + agentType, + issueId, + }); + return undefined; + } + const context = extractLinearContext(payload); let message = await generateAckMessage(agentType, context, project.id); // Append run link footer when enabled for this project - const config = await loadProjectConfig(); - const fullProject = config.fullProjects.find((fp) => fp.id === project.id); if (fullProject?.runLinksEnabled && event.workItemId) { const dashboardUrl = getDashboardUrl(); if (dashboardUrl) { diff --git a/src/router/adapters/trello.ts b/src/router/adapters/trello.ts index 35b923424..f3b101316 100644 --- a/src/router/adapters/trello.ts +++ b/src/router/adapters/trello.ts @@ -7,6 +7,7 @@ * `processRouterWebhook()` function. */ +import { isPmPostingEnabled, resolveUpdateChannel } from '../../config/updateChannel.js'; import { withTrelloCredentials } from '../../trello/client.js'; import type { TriggerRegistry } from '../../triggers/registry.js'; import type { TriggerContext, TriggerResult } from '../../types/index.js'; @@ -140,12 +141,25 @@ export class TrelloRouterAdapter implements RouterPlatformAdapter { ): Promise { if (!event.workItemId) return undefined; try { + const config = await loadProjectConfig(); + const fullProject = config.fullProjects.find((fp) => fp.id === project.id); + + // Skip the PM ack when the agent's update channel disables PM posting. + // The ack comment is communication-only; status moves / labels are + // unaffected. Absent full project ⇒ default channel (post everywhere). + if (fullProject && !isPmPostingEnabled(resolveUpdateChannel(fullProject, agentType))) { + logger.info('Trello ack skipped: PM posting disabled for update channel', { + projectId: project.id, + agentType, + workItemId: event.workItemId, + }); + return undefined; + } + const context = extractTrelloContext(payload); let message = await generateAckMessage(agentType, context, project.id); // Append run link footer when enabled for this project - const config = await loadProjectConfig(); - const fullProject = config.fullProjects.find((fp) => fp.id === project.id); if (fullProject?.runLinksEnabled && event.workItemId) { const dashboardUrl = getDashboardUrl(); if (dashboardUrl) { diff --git a/src/triggers/shared/agent-execution-runtime.ts b/src/triggers/shared/agent-execution-runtime.ts index 2b72b991f..819cd0b47 100644 --- a/src/triggers/shared/agent-execution-runtime.ts +++ b/src/triggers/shared/agent-execution-runtime.ts @@ -1,6 +1,7 @@ import { getAgentProfile } from '../../agents/definitions/profiles.js'; import type { LifecycleHooks } from '../../agents/definitions/schema.js'; import { runAgent } from '../../agents/registry.js'; +import { isPmPostingEnabled, resolveUpdateChannel } from '../../config/updateChannel.js'; import { createPMProvider, PMLifecycleManager, resolveProjectPMConfig } from '../../pm/index.js'; import type { AgentResult, CascadeConfig, ProjectConfig } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; @@ -39,7 +40,12 @@ export async function createAgentExecutionContext( const pmProvider = createPMProvider(project); const pmConfig = resolveProjectPMConfig(project); - const lifecycle = new PMLifecycleManager(pmProvider, pmConfig); + // Gate system-driven PM comment posting on the agent's resolved update + // channel. Status moves, label writes, and linkPR remain unaffected — only + // the lifecycle's communication comments are suppressed when PM posting is + // disabled. + const pmPostingEnabled = isPmPostingEnabled(resolveUpdateChannel(project, result.agentType)); + const lifecycle = new PMLifecycleManager(pmProvider, pmConfig, pmPostingEnabled); const lifecycleHooks = await loadLifecycleHooks(result.agentType); let { workItemId, agentInput } = await prepareAgentWorkItem(result, project.id); @@ -112,7 +118,7 @@ export async function runPostAgentSideEffects( context.agentType, agentResult, context.workItemId, - context.project.id, + context.project, context.result.prNumber, ); } diff --git a/src/triggers/shared/agent-pm-summary.ts b/src/triggers/shared/agent-pm-summary.ts index faa75c2c7..289851d94 100644 --- a/src/triggers/shared/agent-pm-summary.ts +++ b/src/triggers/shared/agent-pm-summary.ts @@ -1,5 +1,6 @@ +import { isPmPostingEnabled, resolveUpdateChannel } from '../../config/updateChannel.js'; import { getSessionState } from '../../gadgets/sessionState.js'; -import type { AgentResult } from '../../types/index.js'; +import type { AgentResult, ProjectConfig } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; import { isOutputBasedAgent, @@ -16,16 +17,30 @@ import { resolveWorkItemId } from './agent-work-items.js'; * Handles two cases: * - review agent: structured session state (reviewBody/reviewEvent/reviewUrl) * - output-based agents: AgentResult.output with per-agent-type formatting + * + * The summary/review comment is communication-only, so it is gated on the + * agent's resolved update channel: when PM posting is disabled the function + * early-returns without posting (workflow actions are gated elsewhere). */ export async function postAgentSummaryToPM( agentType: string, agentResult: AgentResult, workItemId: string | undefined, - projectId: string, + project: ProjectConfig, prNumber: number | undefined, ): Promise { if (!agentResult.success || !PM_SUMMARY_AGENT_TYPES.has(agentType)) return; + if (!isPmPostingEnabled(resolveUpdateChannel(project, agentType))) { + logger.info('Agent PM summary skipped: PM posting disabled for update channel', { + agentType, + projectId: project.id, + }); + return; + } + + const projectId = project.id; + if (isOutputBasedAgent(agentType)) { const resolvedWorkItemId = await resolveWorkItemId(workItemId, projectId, prNumber); if (!resolvedWorkItemId) { diff --git a/tests/unit/backends/progressLifecycle.test.ts b/tests/unit/backends/progressLifecycle.test.ts index 649e897e2..6bee5762f 100644 --- a/tests/unit/backends/progressLifecycle.test.ts +++ b/tests/unit/backends/progressLifecycle.test.ts @@ -282,3 +282,95 @@ describe('buildProgressMonitorConfig', () => { expect(config.repoDir).toBeUndefined(); }); }); + +describe('buildProgressMonitorConfig update-channel gating', () => { + // PM progress posting (config.trello) and SCM progress posting (config.github) + // are gated on the agent's resolved update channel. ProgressMonitor no-ops + // each poster when its config block is absent, so gating = omitting the block. + function makeGatedInput(channel: string) { + return makeInput({ + workItemId: 'card-abc', + prNumber: 42, + repoFullName: 'acme/widgets', + project: makeProject({ + agentUpdateChannels: { implementation: channel as never }, + }), + }); + } + + it('omits trello (PM) but keeps github (SCM) when channel is scm-only', () => { + const config = buildProgressMonitorConfig( + makeGatedInput('scm-only'), + 'implementation', + mockLogWriter, + '/repo', + false, + 'test-engine', + 'model-x', + ); + expect(config.trello).toBeUndefined(); + expect(config.github).toEqual({ owner: 'acme', repo: 'widgets' }); + }); + + it('omits github (SCM) but keeps trello (PM) when channel is pm-only', () => { + const config = buildProgressMonitorConfig( + makeGatedInput('pm-only'), + 'implementation', + mockLogWriter, + '/repo', + false, + 'test-engine', + 'model-x', + ); + expect(config.trello).toEqual({ workItemId: 'card-abc' }); + expect(config.github).toBeUndefined(); + }); + + it('omits both trello and github when channel is none', () => { + const config = buildProgressMonitorConfig( + makeGatedInput('none'), + 'implementation', + mockLogWriter, + '/repo', + false, + 'test-engine', + 'model-x', + ); + expect(config.trello).toBeUndefined(); + expect(config.github).toBeUndefined(); + }); + + it('keeps both trello and github when channel is both', () => { + const config = buildProgressMonitorConfig( + makeGatedInput('both'), + 'implementation', + mockLogWriter, + '/repo', + false, + 'test-engine', + 'model-x', + ); + expect(config.trello).toEqual({ workItemId: 'card-abc' }); + expect(config.github).toEqual({ owner: 'acme', repo: 'widgets' }); + }); + + it('defaults to both surfaces when the project has no channel override for the agent', () => { + const input = makeInput({ + workItemId: 'card-abc', + prNumber: 42, + repoFullName: 'acme/widgets', + project: makeProject({ agentUpdateChannels: { review: 'none' as never } }), + }); + const config = buildProgressMonitorConfig( + input, + 'implementation', + mockLogWriter, + '/repo', + false, + 'test-engine', + 'model-x', + ); + expect(config.trello).toEqual({ workItemId: 'card-abc' }); + expect(config.github).toEqual({ owner: 'acme', repo: 'widgets' }); + }); +}); diff --git a/tests/unit/pm/lifecycle.test.ts b/tests/unit/pm/lifecycle.test.ts index 079078cbb..fd4ec219b 100644 --- a/tests/unit/pm/lifecycle.test.ts +++ b/tests/unit/pm/lifecycle.test.ts @@ -660,6 +660,108 @@ describe('pm/lifecycle', () => { ); }); }); + + // ===================================================================== + // pmPostingEnabled gating (MNG-1684) + // + // When constructed with pmPostingEnabled=false, the manager suppresses + // every communication-only comment post (success fallback, failure, + // budget exceeded/warning, error) while leaving status moves, label + // add/remove, and linkPR untouched. + // ===================================================================== + describe('pmPostingEnabled=false suppresses comments but not moves/labels/linkPR', () => { + let mutedManager: PMLifecycleManager; + + beforeEach(() => { + mutedManager = new PMLifecycleManager(mockProvider, pmConfig, false); + }); + + it('handleFailure adds the error label but posts no comment', async () => { + await mutedManager.handleFailure('work-item-1', 'Something went wrong'); + + expect(mockProvider.addLabel).toHaveBeenCalledWith('work-item-1', 'label-error'); + expect(mockProvider.addComment).not.toHaveBeenCalled(); + }); + + it('handleBudgetExceeded still toggles labels but posts no comment', async () => { + await mutedManager.handleBudgetExceeded('work-item-1', 5.5, 5.0); + + expect(mockProvider.removeLabel).toHaveBeenCalledWith('work-item-1', 'label-proc'); + expect(mockProvider.addLabel).toHaveBeenCalledWith('work-item-1', 'label-error'); + expect(mockProvider.addComment).not.toHaveBeenCalled(); + }); + + it('handleBudgetWarning still adds the error label but posts no comment', async () => { + await mutedManager.handleBudgetWarning('work-item-1', 4.95, 5.0); + + expect(mockProvider.addLabel).toHaveBeenCalledWith('work-item-1', 'label-error'); + expect(mockProvider.addComment).not.toHaveBeenCalled(); + }); + + it('handleError adds the error label but posts no comment', async () => { + await mutedManager.handleError('work-item-1', 'Database connection failed'); + + expect(mockProvider.addLabel).toHaveBeenCalledWith('work-item-1', 'label-error'); + expect(mockProvider.addComment).not.toHaveBeenCalled(); + }); + + it('prepareForAgent still adds/removes labels and moves the work item', async () => { + await mutedManager.prepareForAgent('work-item-1', { moveOnPrepare: 'inProgress' }); + + expect(mockProvider.addLabel).toHaveBeenCalledWith('work-item-1', 'label-proc'); + expect(mockProvider.removeLabel).toHaveBeenCalledWith('work-item-1', 'label-ready'); + expect(mockProvider.moveWorkItem).toHaveBeenCalledWith('work-item-1', 'list-progress'); + expect(mockProvider.addComment).not.toHaveBeenCalled(); + }); + + it('handleSuccess still adds the processed label, moves, and calls linkPR', async () => { + await mutedManager.handleSuccess( + 'work-item-1', + { moveOnSuccess: 'inReview', linkPR: true }, + 'https://github.com/owner/repo/pull/123', + ); + + expect(mockProvider.addLabel).toHaveBeenCalledWith('work-item-1', 'label-done'); + expect(mockProvider.moveWorkItem).toHaveBeenCalledWith('work-item-1', 'list-review'); + expect(mockProvider.linkPR).toHaveBeenCalledWith( + 'work-item-1', + 'https://github.com/owner/repo/pull/123', + 'Pull Request #123', + ); + expect(mockProvider.addComment).not.toHaveBeenCalled(); + }); + + it('handleSuccess suppresses the PR-created comment fallback when linkPR fails', async () => { + vi.mocked(mockProvider.linkPR).mockRejectedValue(new Error('Permission denied')); + + await mutedManager.handleSuccess( + 'work-item-1', + { linkPR: true }, + 'https://github.com/owner/repo/pull/123', + ); + + // linkPR is attempted (not gated) ... + expect(mockProvider.linkPR).toHaveBeenCalled(); + // ... but the communication-only fallback comment is suppressed. + expect(mockProvider.addComment).not.toHaveBeenCalled(); + expect(mockProvider.updateComment).not.toHaveBeenCalled(); + }); + + it('handleSuccess suppresses the progress-comment update fallback when linkPR fails', async () => { + vi.mocked(mockProvider.linkPR).mockRejectedValue(new Error('Permission denied')); + + await mutedManager.handleSuccess( + 'work-item-1', + { linkPR: true }, + 'https://github.com/owner/repo/pull/123', + 'progress-comment-id', + ); + + expect(mockProvider.linkPR).toHaveBeenCalled(); + expect(mockProvider.updateComment).not.toHaveBeenCalled(); + expect(mockProvider.addComment).not.toHaveBeenCalled(); + }); + }); }); // ========================================================================= diff --git a/tests/unit/pm/webhook-handler.test.ts b/tests/unit/pm/webhook-handler.test.ts index cf44a6c9e..64fd69e98 100644 --- a/tests/unit/pm/webhook-handler.test.ts +++ b/tests/unit/pm/webhook-handler.test.ts @@ -56,6 +56,7 @@ vi.mock('../../../src/config/provider.js', () => ({ })); import { loadProjectConfigById } from '../../../src/config/provider.js'; +import { PMLifecycleManager } from '../../../src/pm/lifecycle.js'; import { processPMWebhook } from '../../../src/pm/webhook-handler.js'; import { checkAgentTypeConcurrency } from '../../../src/router/agent-type-lock.js'; import { runAgentExecutionPipeline } from '../../../src/triggers/shared/agent-execution.js'; @@ -63,6 +64,7 @@ import { startWatchdog } from '../../../src/utils/index.js'; const mockStartWatchdog = vi.mocked(startWatchdog); const mockRunAgentExecutionPipeline = vi.mocked(runAgentExecutionPipeline); +const mockPMLifecycleManager = vi.mocked(PMLifecycleManager); // ============================================================================ // PMIntegration factory @@ -242,6 +244,49 @@ describe('processPMWebhook', () => { expect(integration.withCredentials).toHaveBeenCalled(); }); + // MNG-1684 review follow-up: the last-resort `handleError` lifecycle on the + // operational-fault catch path must respect the agent's resolved update + // channel, mirroring the gated inner lifecycle in createAgentExecutionContext. + describe('error-path lifecycle PM posting gate', () => { + it('constructs the error lifecycle with pmPostingEnabled=true by default (channel: both)', async () => { + const integration = createMockIntegration(); + const registry = createMockRegistry(); + + await processPMWebhook(integration as never, { type: 'card_moved' }, registry as never); + + expect(mockPMLifecycleManager).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + true, + ); + }); + + it('constructs the error lifecycle with pmPostingEnabled=false when the agent channel disables PM posting', async () => { + const integration = createMockIntegration({ + lookupProject: vi.fn().mockResolvedValue({ + project: { + id: 'project-1', + name: 'Test Project', + repo: 'owner/repo', + baseBranch: 'main', + watchdogTimeoutMs: 120000, + agentUpdateChannels: { implementation: 'scm-only' }, + }, + config: { projects: [] }, + }), + }); + const registry = createMockRegistry(); + + await processPMWebhook(integration as never, { type: 'card_moved' }, registry as never); + + expect(mockPMLifecycleManager).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + false, + ); + }); + }); + // 2026-05-11: preferredProjectId path. Closes the third bug in the // chain (#1332, #1337 fixed the router; this fixes the worker-side // re-resolution). When the router has already chosen the correct diff --git a/tests/unit/router/adapters/github.test.ts b/tests/unit/router/adapters/github.test.ts index 969595557..7c5753828 100644 --- a/tests/unit/router/adapters/github.test.ts +++ b/tests/unit/router/adapters/github.test.ts @@ -546,6 +546,102 @@ describe('GitHubRouterAdapter', () => { expect(postGitHubAck).not.toHaveBeenCalled(); expect(ackResult).toBeUndefined(); }); + + // MNG-1684: system-driven acks are gated on the agent's resolved update + // channel. The PR ack is an SCM-surface post; the PM-focused ack is a + // PM-surface post — each is gated by its own posting flag. + it('skips the GitHub PR ack when the update channel disables SCM posting', async () => { + vi.mocked(isPMFocusedAgent).mockResolvedValue(false); + vi.mocked(loadProjectConfig).mockResolvedValue({ + projects: [mockProject], + fullProjects: [ + { id: 'p1', repo: 'owner/repo', agentUpdateChannels: { review: 'pm-only' } } as never, + ], + }); + + const ackResult = await adapter.postAck( + { + projectIdentifier: 'owner/repo', + eventType: 'pull_request', + workItemId: '42', + isCommentEvent: false, + // @ts-expect-error extended field + repoFullName: 'owner/repo', + }, + {}, + mockProject, + 'review', + ); + + expect(ackResult).toBeUndefined(); + expect(resolveGitHubTokenForAckByAgent).not.toHaveBeenCalled(); + expect(postGitHubAck).not.toHaveBeenCalled(); + }); + + it('skips the PM-focused agent ack when the update channel disables PM posting', async () => { + vi.mocked(isPMFocusedAgent).mockResolvedValue(true); + vi.mocked(loadProjectConfig).mockResolvedValue({ + projects: [mockProject], + fullProjects: [ + { + id: 'p1', + repo: 'owner/repo', + agentUpdateChannels: { 'backlog-manager': 'scm-only' }, + } as never, + ], + }); + + const ackResult = await adapter.postAck( + { + projectIdentifier: 'owner/repo', + eventType: 'pull_request', + workItemId: 'card-123', + isCommentEvent: false, + // @ts-expect-error extended field + repoFullName: 'owner/repo', + }, + {}, + mockProject, + 'backlog-manager', + { agentType: 'backlog-manager', agentInput: {}, workItemId: 'card-123' }, + ); + + expect(ackResult).toBeUndefined(); + expect(dispatchPMAck).not.toHaveBeenCalled(); + }); + + it('still posts the GitHub PR ack when SCM posting stays enabled (both)', async () => { + vi.mocked(isPMFocusedAgent).mockResolvedValue(false); + vi.mocked(loadProjectConfig).mockResolvedValue({ + projects: [mockProject], + fullProjects: [ + { id: 'p1', repo: 'owner/repo', agentUpdateChannels: { review: 'both' } } as never, + ], + }); + vi.mocked(resolveGitHubTokenForAckByAgent).mockResolvedValue({ + token: 'ghp_test', + project: { id: 'p1' }, + } as never); + vi.mocked(extractPRNumber).mockReturnValue(42); + vi.mocked(postGitHubAck).mockResolvedValue(999); + + const ackResult = await adapter.postAck( + { + projectIdentifier: 'owner/repo', + eventType: 'pull_request', + workItemId: '42', + isCommentEvent: false, + // @ts-expect-error extended field + repoFullName: 'owner/repo', + }, + {}, + mockProject, + 'review', + ); + + expect(postGitHubAck).toHaveBeenCalled(); + expect(ackResult?.commentId).toBe(999); + }); }); describe('buildJob', () => { diff --git a/tests/unit/router/adapters/jira.test.ts b/tests/unit/router/adapters/jira.test.ts index 1079678df..02195552d 100644 --- a/tests/unit/router/adapters/jira.test.ts +++ b/tests/unit/router/adapters/jira.test.ts @@ -383,5 +383,31 @@ describe('JiraRouterAdapter', () => { ); expect(ackResult).toBeUndefined(); }); + + // MNG-1684: the PM ack is communication-only and is gated on the agent's + // resolved update channel. + it('skips the PM ack when the update channel disables PM posting', async () => { + vi.mocked(loadProjectConfig).mockResolvedValue({ + projects: [mockProject], + fullProjects: [{ id: 'p1', agentUpdateChannels: { implementation: 'scm-only' } } as never], + }); + + const ackResult = await adapter.postAck( + { + projectIdentifier: 'PROJ', + eventType: 'jira:issue_updated', + workItemId: 'PROJ-1', + isCommentEvent: false, + // @ts-expect-error extended field + issueKey: 'PROJ-1', + }, + {}, + mockProject, + 'implementation', + ); + + expect(ackResult).toBeUndefined(); + expect(postJiraAck).not.toHaveBeenCalled(); + }); }); }); diff --git a/tests/unit/router/adapters/linear.test.ts b/tests/unit/router/adapters/linear.test.ts index 036be8344..05e97c24d 100644 --- a/tests/unit/router/adapters/linear.test.ts +++ b/tests/unit/router/adapters/linear.test.ts @@ -952,6 +952,32 @@ describe('LinearRouterAdapter', () => { ); expect(ackResult).toBeUndefined(); }); + + // MNG-1684: the PM ack is communication-only and is gated on the agent's + // resolved update channel. + it('skips the PM ack when the update channel disables PM posting', async () => { + vi.mocked(loadProjectConfig).mockResolvedValue({ + projects: [mockProject], + fullProjects: [{ id: 'p1', agentUpdateChannels: { implementation: 'scm-only' } } as never], + }); + + const ackResult = await adapter.postAck( + { + projectIdentifier: 'team-abc-123', + eventType: 'create/Issue', + workItemId: 'issue-abc', + isCommentEvent: false, + // @ts-expect-error extended field + projectId: 'p1', + }, + baseLinearPayload, + mockProject, + 'implementation', + ); + + expect(ackResult).toBeUndefined(); + expect(postLinearAck).not.toHaveBeenCalled(); + }); }); describe('buildJob', () => { diff --git a/tests/unit/router/adapters/trello.test.ts b/tests/unit/router/adapters/trello.test.ts index d3a93d822..fdc8727aa 100644 --- a/tests/unit/router/adapters/trello.test.ts +++ b/tests/unit/router/adapters/trello.test.ts @@ -361,6 +361,53 @@ describe('TrelloRouterAdapter', () => { ); expect(ackResult).toBeUndefined(); }); + + // MNG-1684: the PM ack is communication-only and is gated on the agent's + // resolved update channel. + it('skips the PM ack when the update channel disables PM posting', async () => { + vi.mocked(loadProjectConfig).mockResolvedValue({ + projects: [mockProject], + fullProjects: [{ id: 'p1', agentUpdateChannels: { implementation: 'scm-only' } } as never], + }); + + const ackResult = await adapter.postAck( + { + projectIdentifier: 'board1', + eventType: 'commentCard', + workItemId: 'card1', + isCommentEvent: true, + }, + {}, + mockProject, + 'implementation', + ); + + expect(ackResult).toBeUndefined(); + expect(postTrelloAck).not.toHaveBeenCalled(); + }); + + it('still posts the PM ack when the channel keeps PM posting enabled (pm-only)', async () => { + vi.mocked(loadProjectConfig).mockResolvedValue({ + projects: [mockProject], + fullProjects: [{ id: 'p1', agentUpdateChannels: { implementation: 'pm-only' } } as never], + }); + vi.mocked(postTrelloAck).mockResolvedValue('comment-123'); + + const ackResult = await adapter.postAck( + { + projectIdentifier: 'board1', + eventType: 'commentCard', + workItemId: 'card1', + isCommentEvent: true, + }, + {}, + mockProject, + 'implementation', + ); + + expect(postTrelloAck).toHaveBeenCalled(); + expect(ackResult?.commentId).toBe('comment-123'); + }); }); describe('sendReaction - additional paths', () => { diff --git a/tests/unit/triggers/shared/agent-execution.test.ts b/tests/unit/triggers/shared/agent-execution.test.ts index 0d8f6c178..09fef34bd 100644 --- a/tests/unit/triggers/shared/agent-execution.test.ts +++ b/tests/unit/triggers/shared/agent-execution.test.ts @@ -664,12 +664,60 @@ describe('agent PM summary facade delegation', () => { 'respond-to-review', agentResult, 'card-3', - 'project-1', + PROJECT, 42, ); }); }); +// --------------------------------------------------------------------------- +// PMLifecycleManager update-channel gating (MNG-1684, via runAgentExecutionPipeline) +// +// createAgentExecutionContext resolves the agent's update channel and passes +// the PM-posting flag into the PMLifecycleManager constructor (3rd arg). +// --------------------------------------------------------------------------- + +describe('PMLifecycleManager pmPostingEnabled flag (via runAgentExecutionPipeline)', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCreatePMProvider.mockReturnValue({}); + mockResolveProjectPMConfig.mockReturnValue(PM_CONFIG); + mockValidateIntegrations.mockResolvedValue({ valid: true, errors: [] }); + mockCheckBudgetExceeded.mockResolvedValue(null); + mockHandleAgentResultArtifacts.mockResolvedValue(undefined); + mockShouldTriggerDebug.mockResolvedValue(null); + mockRunAgent.mockResolvedValue({ success: true, output: '', runId: 'run-1' }); + }); + + it('constructs PMLifecycleManager with pmPostingEnabled=true by default (channel both)', async () => { + await runAgentExecutionPipeline( + { agentType: 'review', agentInput: {}, workItemId: 'card-1' }, + PROJECT, + CONFIG, + ); + + expect(MockPMLifecycleManager).toHaveBeenCalledWith({}, PM_CONFIG, true); + }); + + it('constructs PMLifecycleManager with pmPostingEnabled=false when the channel disables PM posting', async () => { + const scmOnlyProject = { + id: 'project-1', + repo: 'acme/myapp', + pm: { type: 'trello' }, + trello: { lists: { backlog: 'backlog-list-id' } }, + agentUpdateChannels: { review: 'scm-only' }, + } as unknown as Parameters[0]['project']; + + await runAgentExecutionPipeline( + { agentType: 'review', agentInput: {}, workItemId: 'card-1' }, + scmOnlyProject, + CONFIG, + ); + + expect(MockPMLifecycleManager).toHaveBeenCalledWith({}, PM_CONFIG, false); + }); +}); + // --------------------------------------------------------------------------- // linkPRPostExecution PR title backfill (via runAgentExecutionPipeline) // --------------------------------------------------------------------------- diff --git a/tests/unit/triggers/shared/agent-pm-summary.test.ts b/tests/unit/triggers/shared/agent-pm-summary.test.ts index bf566c4e9..a15829aa4 100644 --- a/tests/unit/triggers/shared/agent-pm-summary.test.ts +++ b/tests/unit/triggers/shared/agent-pm-summary.test.ts @@ -59,6 +59,16 @@ vi.mock('../../../../src/utils/logging.js', () => ({ })); import { postAgentSummaryToPM } from '../../../../src/triggers/shared/agent-pm-summary.js'; +import type { ProjectConfig } from '../../../../src/types/index.js'; + +// Minimal project whose update channel resolves to the default (`both`) for +// every agent type, so PM posting stays enabled exactly like the pre-MNG-1684 +// behavior. Channel-gating tests below supply explicit agentUpdateChannels. +const PROJECT = { id: 'project-1' } as ProjectConfig; + +function projectWithChannel(agentType: string, channel: string): ProjectConfig { + return { id: 'project-1', agentUpdateChannels: { [agentType]: channel } } as ProjectConfig; +} describe('postAgentSummaryToPM', () => { beforeEach(() => { @@ -78,7 +88,7 @@ describe('postAgentSummaryToPM', () => { 'review', { success: true, output: '', runId: 'run-rev', progressCommentId: 'pm-comment-1' }, 'card-1', - 'project-1', + PROJECT, 42, ); @@ -96,7 +106,7 @@ describe('postAgentSummaryToPM', () => { 'implementation', { success: true, output: '', runId: 'run-impl' }, 'card-1', - 'project-1', + PROJECT, undefined, ); @@ -112,7 +122,7 @@ describe('postAgentSummaryToPM', () => { 'review', { success: false, output: '', error: 'review error' }, 'card-1', - 'project-1', + PROJECT, undefined, ); @@ -127,7 +137,7 @@ describe('postAgentSummaryToPM', () => { 'review', { success: true, output: '', runId: 'run-rev' }, 'card-1', - 'project-1', + PROJECT, undefined, ); @@ -149,7 +159,7 @@ describe('postAgentSummaryToPM', () => { 'review', { success: true, output: '', runId: 'run-rev' }, undefined, - 'project-1', + PROJECT, 99, ); @@ -173,7 +183,7 @@ describe('postAgentSummaryToPM', () => { 'review', { success: true, output: '', runId: 'run-rev' }, undefined, - 'project-1', + PROJECT, 55, ); @@ -194,7 +204,7 @@ describe('postAgentSummaryToPM', () => { agentType, { success: true, output, runId: 'run-output', progressCommentId: 'pm-prog' }, 'card-2', - 'project-1', + PROJECT, 10, ); @@ -207,7 +217,7 @@ describe('postAgentSummaryToPM', () => { 'respond-to-ci', { success: true, output: '', runId: 'run-ci-empty' }, 'card-5', - 'project-1', + PROJECT, undefined, ); @@ -219,11 +229,69 @@ describe('postAgentSummaryToPM', () => { 'respond-to-ci', { success: false, output: 'Some output before failure.', error: 'CI fix failed' }, 'card-6', - 'project-1', + PROJECT, undefined, ); expect(mockPostAgentOutputToPM).not.toHaveBeenCalled(); expect(mockPostReviewToPM).not.toHaveBeenCalled(); }); + + // MNG-1684: the summary/review comment is communication-only, so it is gated + // on the agent's resolved update channel. + describe('update-channel gating', () => { + it('early-returns without posting the review summary when PM posting is disabled', async () => { + mockGetSessionState.mockReturnValue({ + reviewBody: 'Looks good', + reviewEvent: 'APPROVE', + reviewUrl: 'https://github.com/acme/myapp/pull/42#pullrequestreview-1', + }); + + await postAgentSummaryToPM( + 'review', + { success: true, output: '', runId: 'run-rev', progressCommentId: 'pm-comment-1' }, + 'card-1', + projectWithChannel('review', 'scm-only'), + 42, + ); + + expect(mockPostReviewToPM).not.toHaveBeenCalled(); + // Gate fires before reading session state / resolving the work item. + expect(mockGetSessionState).not.toHaveBeenCalled(); + expect(mockLookupWorkItemForPR).not.toHaveBeenCalled(); + expect(mockLogger.info).toHaveBeenCalledWith( + 'Agent PM summary skipped: PM posting disabled for update channel', + expect.objectContaining({ agentType: 'review', projectId: 'project-1' }), + ); + }); + + it('early-returns without posting output-based summaries when PM posting is disabled', async () => { + await postAgentSummaryToPM( + 'respond-to-ci', + { success: true, output: 'Fixed CI.', runId: 'run-ci', progressCommentId: 'pm-prog' }, + 'card-2', + projectWithChannel('respond-to-ci', 'none'), + 10, + ); + + expect(mockPostAgentOutputToPM).not.toHaveBeenCalled(); + }); + + it('still posts when the channel keeps PM posting enabled (pm-only)', async () => { + await postAgentSummaryToPM( + 'respond-to-ci', + { success: true, output: 'Fixed CI.', runId: 'run-ci', progressCommentId: 'pm-prog' }, + 'card-2', + projectWithChannel('respond-to-ci', 'pm-only'), + 10, + ); + + expect(mockPostAgentOutputToPM).toHaveBeenCalledWith( + 'card-2', + 'respond-to-ci', + 'Fixed CI.', + 'pm-prog', + ); + }); + }); }); From 7397bb6dc242a950be52b42c8cad3af27b46a697 Mon Sep 17 00:00:00 2001 From: aaight Date: Thu, 25 Jun 2026 13:56:19 +0200 Subject: [PATCH 15/18] docs(agents): document per-agent updateChannel setting and gating boundary (#1448) Document the per-agent `updateChannel` setting (values none/scm-only/pm-only/both, default both, the PM/SCM posting matrix, and the communication-only vs workflow boundary) implemented in MNG-1684/MNG-1685. - CLAUDE.md (AGENTS.md follows via symlink): new "Agent update channel" section. - docs/architecture/04-agent-system.md: gated vs not-gated posting surface map. - docs/architecture/08-config-credentials.md: per-agent config field + resolution. - CHANGELOG.md: Unreleased/Added entry. Co-authored-by: Cascade Bot Co-authored-by: Claude Opus 4.8 --- CHANGELOG.md | 2 ++ CLAUDE.md | 15 +++++++++ docs/architecture/04-agent-system.md | 39 ++++++++++++++++++++++ docs/architecture/08-config-credentials.md | 31 +++++++++++++++++ 4 files changed, 87 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 449b92beb..e5f5acc9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable user-visible changes to CASCADE are documented here. The format is l ### Added +- **The per-agent `updateChannel` setting is now documented** ([MNG-1687](https://linear.app/issue/MNG-1687)). Each agent type has an optional `updateChannel` (`none` / `scm-only` / `pm-only` / `both`, default `both`) that gates *where* it posts **communication-only** status updates — independently for the **PM** (work-item comments) and **SCM** (pull-request comments / reviews) surfaces. The catalog, resolver, and posting matrix live in `src/config/updateChannel.ts`; the per-agent value is stored in the `agent_configs.update_channel` column (`NULL` / absent / unrecognized → inherit the default `both`) and read at runtime via `resolveUpdateChannel(project, agentType)`. The channel silences acks, progress updates, lifecycle status comments, agent summaries / reviews, and the agent's own PM/SCM posting tools (`PostComment`; `PostPRComment`, `UpdatePRComment`, `CreatePRReview`, `ReplyToReviewComment`) in both the native-tool and LLMist engine paths — but never PR creation, status moves, label writes, checklist sync, PR linking, friction reports, or the "eyes" reaction. Documented in `CLAUDE.md` / `AGENTS.md` (kept byte-identical), `docs/architecture/04-agent-system.md` (gated-vs-not-gated surface map), and `docs/architecture/08-config-credentials.md` (per-agent config field). Closes [MNG-1687](https://linear.app/mongrel/issue/MNG-1687). + - **`cascade-tools` now suggests the closest command when an agent typos a topic or subcommand** ([MNG-1442](https://linear.app/issue/MNG-1442)). `bin/cascade-tools.js` registers an oclif `command_not_found` hook that turns command typos into the same structured spec-014 envelope every other CLI failure emits: JSON on stdout, a one-line prose summary on stderr, and a runnable `did you mean` hint when within the shared Levenshtein budget (MNG-1440). Unknown top-level topics (`cascade-tools sm get-pr-diff`) surface a topic enumeration in `expected` and a hint that preserves the user's trailing segments (`did you mean 'cascade-tools scm get-pr-diff'?`). Known-topic / unknown-subcommand typos (`cascade-tools pm reaad-work-item`) surface the topic's subcommand enumeration and a corrected hint (`did you mean 'cascade-tools pm read-work-item'?`). Far-away typos drop `hint` but still surface `expected` so the agent has a concrete recovery path. Exit code is **`2`** for `unknown-command` — preserved from oclif's historical `command_not_found` default and distinct from every other envelope's exit code `1`; existing exit-code consumers see no change. The hook lives at `src/cli/_shared/command-not-found-hook.ts`, intentionally inside `_shared/` so oclif's command-discovery glob in `bin/cascade-tools.js` excludes it. It is wired through `pjson.oclif.hooks` so oclif loads it dynamically only when needed — no static import is added, which preserves the existing friendly `dist/cli/bootstrap.js` missing path in the entrypoint. The pure suggestion logic lives in `src/cli/_shared/commandSuggestions.ts` (MNG-1441) and is unit-tested directly without booting oclif; candidates come strictly from the loaded oclif config (`config.commandIDs` plus non-hidden `pjson.oclif.topics`), so the `cascade-tools` binary never suggests dashboard topics that its discovery glob excludes. Existing `unknown-flag` handling from `createCLICommand()` is untouched. Closes [MNG-1442](https://linear.app/mongrel/issue/MNG-1442). ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 2bcf50104..8c0c0ca75 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,6 +104,21 @@ cascade projects credentials-set --key GITHUB_TOKEN_REVIEWER --value ghp_.. - `check-suite-success` checks reviews from the **reviewer** persona specifically. - All trigger handlers use `isCascadeBot(login)` to filter self-events. +## Agent update channel + +Each agent type has an optional **`updateChannel`** that gates *where* it posts **communication-only** status updates back to humans. Two independent posting surfaces exist: **PM** (work-item comments on the Trello card / JIRA issue / Linear issue) and **SCM** (comments and reviews on the GitHub PR). The channel catalog, resolver, and posting-matrix helpers are the single source of truth in `src/config/updateChannel.ts`. + +| `updateChannel` | PM posting | SCM posting | +|---|:---:|:---:| +| `none` | ❌ | ❌ | +| `pm-only` | ✅ | ❌ | +| `scm-only` | ❌ | ✅ | +| `both` (default) | ✅ | ✅ | + +**Resolution.** The per-agent override is stored in the `agent_configs.update_channel` column (one row per `(projectId, agentType)`). A `NULL`, absent, or unrecognized value inherits the default **`both`** — the historical "post everywhere" behavior. The config mapper validates the stored string against the channel catalog (`UPDATE_CHANNELS`) and surfaces the per-agent map as `ProjectConfig.agentUpdateChannels`; runtime code reads it via `resolveUpdateChannel(project, agentType)` and branches on `isPmPostingEnabled(channel)` / `isScmPostingEnabled(channel)`. + +**Communication-only, not workflow.** The channel only silences human-facing status chatter; it never blocks an agent from doing real work. **Gated** surfaces: system-driven **acks** (router PR / PM-focused-agent ack comments), **progress** updates (the progress monitor's PM/SCM posters), **lifecycle comments** (the `PR created` fallback plus failure / budget-exceeded / budget-warning / error comments in `PMLifecycleManager`), agent **summaries / reviews** posted back to the work item, and the agent's own **posting tools** — `filterPostingGadgetNames` drops the disabled surface's communication-only gadgets (PM: `PostComment`; SCM: `PostPRComment`, `UpdatePRComment`, `CreatePRReview`, `ReplyToReviewComment`) in **both** the native-tool (`buildExecutionPlan`) and LLMist engine paths, so a `none` / `pm-only` / `scm-only` agent literally cannot call a disabled-surface comment/review tool. **Not gated** (workflow actions always run): PR creation (`CreatePR`), status moves (`MoveWorkItem` and lifecycle `moveOnPrepare` / `moveOnSuccess`), label add/remove, checklist sync (`syncChecklist`), PR linking (`linkPR`), friction reporting (`ReportFriction`), and the "eyes" acknowledgment reaction. + ## Agent triggers Trigger format is category-prefixed: `{category}:{event}` diff --git a/docs/architecture/04-agent-system.md b/docs/architecture/04-agent-system.md index 3c3552c59..7c13623a5 100644 --- a/docs/architecture/04-agent-system.md +++ b/docs/architecture/04-agent-system.md @@ -312,6 +312,45 @@ PM card management during agent execution: | `linkPR` | Link the created PR to the work item | | `syncChecklist` | Sync todo list back to PM card checklists | +## Update Channel (posting surfaces) + +Each agent type carries an optional **`updateChannel`** (`none` / `scm-only` / `pm-only` / `both`, default `both`) that gates *where* the agent posts **communication-only** status updates. It distinguishes two posting surfaces — **PM** (work-item comments) and **SCM** (PR comments and reviews) — without ever touching the agent's real work. The catalog, resolver, and posting-matrix helpers live in `src/config/updateChannel.ts`; per-agent values are configured in the `agent_configs.update_channel` column and surfaced on `ProjectConfig.agentUpdateChannels` (see [`08-config-credentials.md`](./08-config-credentials.md#agent-update-channel)). + +| `updateChannel` | PM posting | SCM posting | +|---|:---:|:---:| +| `none` | ❌ | ❌ | +| `pm-only` | ✅ | ❌ | +| `scm-only` | ❌ | ✅ | +| `both` (default) | ✅ | ✅ | + +Runtime code resolves the channel with `resolveUpdateChannel(project, agentType)` and branches on `isPmPostingEnabled()` / `isScmPostingEnabled()`. A `NULL` / absent / unrecognized value inherits the default `both`. + +### Gated posting surfaces (communication-only) + +These exist purely to post human-facing status updates, so suppressing them never stops the agent from reading code, opening PRs, or moving cards: + +| Surface | Where | Gating | +|---|---|---| +| Router ack comments | `src/router/adapters/{github,trello,jira,linear}.ts` | PM-focused-agent ack needs PM posting; regular PR ack needs SCM posting | +| Progress updates | `buildProgressMonitorConfig` (`src/backends/progressLifecycle.ts`) | Omits the PM (`trello`) / SCM (`github`) progress poster the channel disables | +| Lifecycle comments | `PMLifecycleManager` (`src/pm/lifecycle.ts`) | `pmPostingEnabled` suppresses the `PR created` fallback, failure, budget-exceeded/warning, and error comments — labels / moves / `linkPR` still run | +| Agent summary / review | `postAgentSummaryToPM` (`src/triggers/shared/agent-pm-summary.ts`) | Early-returns when PM posting is disabled | +| Agent posting tools | `buildExecutionPlan` (`src/backends/secretOrchestrator.ts`) + LLMist (`src/backends/llmist/index.ts`) | `filterPostingGadgetNames` removes disabled-surface gadgets — PM: `PostComment`; SCM: `PostPRComment`, `UpdatePRComment`, `CreatePRReview`, `ReplyToReviewComment` | + +The tool-level gate runs in **both** engine families (native-tool and LLMist), so a disabled channel means the agent's tool list never includes the silenced surface's comment/review gadget — it cannot post even if instructed. + +### Not gated (workflow actions) + +The channel is communication-only; these always run regardless of channel: + +- **PR creation** (`CreatePR`) +- **Status moves** (`MoveWorkItem`, plus lifecycle `moveOnPrepare` / `moveOnSuccess`) +- **Label** add/remove (processing / processed / error) +- **Checklist sync** (`syncChecklist`) +- **PR linking** (`linkPR`) +- **Friction reporting** (`ReportFriction`) +- The **"eyes"** acknowledgment reaction on PRs + ## Agent Profiles `src/agents/definitions/profiles.ts` diff --git a/docs/architecture/08-config-credentials.md b/docs/architecture/08-config-credentials.md index a0b6ae6d2..b48d0d107 100644 --- a/docs/architecture/08-config-credentials.md +++ b/docs/architecture/08-config-credentials.md @@ -49,12 +49,43 @@ interface ProjectConfig { agentEngine?: { default: string; overrides: Record }; engineSettings?: EngineSettings; agentEngineSettings?: Record; + agentUpdateChannels?: Record; // per-agent posting gate; default 'both' runLinksEnabled: boolean; maxInFlightItems?: number; // hard cap on TODO+IN_PROGRESS+IN_REVIEW; default 1 // ... PM config (trello/jira/linear), agent models, snapshot settings } ``` +### Agent update channel + +`src/config/updateChannel.ts` + +Each agent type has an optional **`updateChannel`** that gates *where* the agent +posts **communication-only** status updates — independently for the **PM** +(work-item comments) and **SCM** (PR comments / reviews) surfaces. It is a +per-agent override stored in the `agent_configs.update_channel` column (one row +per `(projectId, agentType)`), surfaced on `ProjectConfig.agentUpdateChannels` +by the config mapper (`buildAgentMaps`) and read at runtime via +`resolveUpdateChannel(project, agentType)`. + +| `updateChannel` | PM posting | SCM posting | +|---|:---:|:---:| +| `none` | ❌ | ❌ | +| `pm-only` | ✅ | ❌ | +| `scm-only` | ❌ | ✅ | +| `both` (default) | ✅ | ✅ | + +A `NULL` / absent column — or any value the config mapper does not recognize — +inherits the default **`both`** (post everywhere, the historical behavior). The +mapper validates the stored string against the channel catalog +(`UPDATE_CHANNELS`) so a stale or invalid value never breaks config loading. The +channel is **communication-only**: it suppresses acks, progress updates, +lifecycle status comments, agent summaries, and the agent's own comment/review +tools, but never PR creation, status moves, label writes, checklist sync, PR +linking, friction reports, or the "eyes" reaction. See +[`04-agent-system.md`](./04-agent-system.md#update-channel-posting-surfaces) for +the full gated-vs-not-gated surface map. + ### PM workflow slots PM provider config maps CASCADE lifecycle concepts onto provider-native lists or statuses. The friction-reporting slot is optional but recognized consistently across providers: From 88deacb7ca806f5f266541c2c530b40b4a1d87ce Mon Sep 17 00:00:00 2001 From: aaight Date: Thu, 25 Jun 2026 14:22:52 +0200 Subject: [PATCH 16/18] feat(agents): set agent update channel via tRPC API and CLI (#1449) Adds the operator write path for the per-agent updateChannel override: - agentConfigsRepository create/update accept and persist updateChannel?: string | null (mirrors the maxConcurrency shape) - tRPC agentConfigs.create/.update inputs add updateChannel: UpdateChannelSchema.nullish(), forwarded with the same spread used for maxConcurrency - CLI agents create/update add --update-channel (none/scm-only/pm-only/both), forwarded into the mutation Value persists and round-trips through config (NULL preserved); behavior is still effectively `both` until the gating stories land. Co-authored-by: Cascade Bot Co-authored-by: Claude Opus 4.8 --- src/api/routers/agentConfigs.ts | 4 + src/cli/dashboard/agents/create.ts | 6 + src/cli/dashboard/agents/update.ts | 6 + src/db/repositories/agentConfigsRepository.ts | 3 + .../db/agentConfigsRepository.test.ts | 78 ++++++++++++ tests/unit/api/routers/agentConfigs.test.ts | 116 ++++++++++++++++++ .../unit/cli/dashboard/agents/agents.test.ts | 44 +++++++ .../agentConfigsRepository.test.ts | 37 ++++++ 8 files changed, 294 insertions(+) diff --git a/src/api/routers/agentConfigs.ts b/src/api/routers/agentConfigs.ts index b6f7cbfd5..c5c6be67f 100644 --- a/src/api/routers/agentConfigs.ts +++ b/src/api/routers/agentConfigs.ts @@ -9,6 +9,7 @@ import { } from '../../agents/prompts/index.js'; import { getEngineCatalog, registerBuiltInEngines } from '../../backends/index.js'; import { EngineSettingsSchema } from '../../config/engineSettings.js'; +import { UpdateChannelSchema } from '../../config/updateChannel.js'; import { getDb } from '../../db/client.js'; import { loadPartials } from '../../db/repositories/partialsRepository.js'; import { @@ -78,6 +79,7 @@ export const agentConfigsRouter = router({ maxConcurrency: z.number().int().positive().nullish(), systemPrompt: z.string().nullish(), taskPrompt: z.string().nullish(), + updateChannel: UpdateChannelSchema.nullish(), }), ) .mutation(async ({ ctx, input }) => { @@ -98,6 +100,7 @@ export const agentConfigsRouter = router({ ...(input.maxConcurrency !== undefined ? { maxConcurrency: input.maxConcurrency } : {}), ...(input.systemPrompt !== undefined ? { systemPrompt: input.systemPrompt } : {}), ...(input.taskPrompt !== undefined ? { taskPrompt: input.taskPrompt } : {}), + ...(input.updateChannel !== undefined ? { updateChannel: input.updateChannel } : {}), }); }), @@ -113,6 +116,7 @@ export const agentConfigsRouter = router({ maxConcurrency: z.number().int().positive().nullish(), systemPrompt: z.string().nullish(), taskPrompt: z.string().nullish(), + updateChannel: UpdateChannelSchema.nullish(), }), ) .mutation(async ({ ctx, input }) => { diff --git a/src/cli/dashboard/agents/create.ts b/src/cli/dashboard/agents/create.ts index 9e6a7cc0e..e3e8283c7 100644 --- a/src/cli/dashboard/agents/create.ts +++ b/src/cli/dashboard/agents/create.ts @@ -1,4 +1,5 @@ import { Flags } from '@oclif/core'; +import type { UpdateChannel } from '../../../config/updateChannel.js'; import { DashboardCommand } from '../_shared/base.js'; export default class AgentsCreate extends DashboardCommand { @@ -18,6 +19,10 @@ export default class AgentsCreate extends DashboardCommand { 'max-iterations': Flags.integer({ description: 'Max iterations override' }), engine: Flags.string({ description: 'Agent engine override' }), 'max-concurrency': Flags.integer({ description: 'Max concurrent runs per project' }), + 'update-channel': Flags.string({ + description: 'Where this agent posts status updates', + options: ['none', 'scm-only', 'pm-only', 'both'], + }), }; async run(): Promise { @@ -32,6 +37,7 @@ export default class AgentsCreate extends DashboardCommand { maxIterations: flags['max-iterations'], agentEngine: flags.engine, maxConcurrency: flags['max-concurrency'], + updateChannel: flags['update-channel'] as UpdateChannel | undefined, }), ); diff --git a/src/cli/dashboard/agents/update.ts b/src/cli/dashboard/agents/update.ts index 609b58fc9..1f847b519 100644 --- a/src/cli/dashboard/agents/update.ts +++ b/src/cli/dashboard/agents/update.ts @@ -1,4 +1,5 @@ import { Args, Flags } from '@oclif/core'; +import type { UpdateChannel } from '../../../config/updateChannel.js'; import { DashboardCommand } from '../_shared/base.js'; export default class AgentsUpdate extends DashboardCommand { @@ -15,6 +16,10 @@ export default class AgentsUpdate extends DashboardCommand { 'max-iterations': Flags.integer({ description: 'Max iterations override' }), engine: Flags.string({ description: 'Agent engine override' }), 'max-concurrency': Flags.integer({ description: 'Max concurrent runs per project' }), + 'update-channel': Flags.string({ + description: 'Where this agent posts status updates', + options: ['none', 'scm-only', 'pm-only', 'both'], + }), }; async run(): Promise { @@ -29,6 +34,7 @@ export default class AgentsUpdate extends DashboardCommand { maxIterations: flags['max-iterations'], agentEngine: flags.engine, maxConcurrency: flags['max-concurrency'], + updateChannel: flags['update-channel'] as UpdateChannel | undefined, }), ); diff --git a/src/db/repositories/agentConfigsRepository.ts b/src/db/repositories/agentConfigsRepository.ts index 6df74c6c1..c55d7820c 100644 --- a/src/db/repositories/agentConfigsRepository.ts +++ b/src/db/repositories/agentConfigsRepository.ts @@ -22,6 +22,7 @@ export async function createAgentConfig(data: { maxConcurrency?: number | null; systemPrompt?: string | null; taskPrompt?: string | null; + updateChannel?: string | null; }) { const db = getDb(); const [row] = await db @@ -36,6 +37,7 @@ export async function createAgentConfig(data: { maxConcurrency: data.maxConcurrency, systemPrompt: data.systemPrompt, taskPrompt: data.taskPrompt, + updateChannel: data.updateChannel, }) .returning({ id: agentConfigs.id }); return row; @@ -52,6 +54,7 @@ export async function updateAgentConfig( maxConcurrency?: number | null; systemPrompt?: string | null; taskPrompt?: string | null; + updateChannel?: string | null; }, ) { const db = getDb(); diff --git a/tests/integration/db/agentConfigsRepository.test.ts b/tests/integration/db/agentConfigsRepository.test.ts index bfe3c5997..6f24ef8a6 100644 --- a/tests/integration/db/agentConfigsRepository.test.ts +++ b/tests/integration/db/agentConfigsRepository.test.ts @@ -285,6 +285,84 @@ describe('agentConfigsRepository (integration)', () => { }); }); + // ========================================================================= + // updateChannel round-trip + // ========================================================================= + + describe('updateChannel persistence', () => { + it('persists updateChannel via createAgentConfig and round-trips', async () => { + await createAgentConfig({ + projectId: 'test-project', + agentType: 'implementation', + updateChannel: 'pm-only', + }); + + const configs = await listAgentConfigs({ projectId: 'test-project' }); + expect(configs).toHaveLength(1); + expect(configs[0].updateChannel).toBe('pm-only'); + }); + + it('defaults updateChannel to NULL when not provided', async () => { + await createAgentConfig({ + projectId: 'test-project', + agentType: 'review', + }); + + const configs = await listAgentConfigs({ projectId: 'test-project' }); + expect(configs[0].updateChannel).toBeNull(); + }); + + it('persists an explicit null updateChannel as NULL', async () => { + await createAgentConfig({ + projectId: 'test-project', + agentType: 'splitting', + updateChannel: null, + }); + + const configs = await listAgentConfigs({ projectId: 'test-project' }); + expect(configs[0].updateChannel).toBeNull(); + }); + + it('updates updateChannel via updateAgentConfig and round-trips', async () => { + const { id } = await createAgentConfig({ + projectId: 'test-project', + agentType: 'implementation', + }); + + await updateAgentConfig(id, { updateChannel: 'scm-only' }); + + const configs = await listAgentConfigs({ projectId: 'test-project' }); + expect(configs[0].updateChannel).toBe('scm-only'); + }); + + it('can reset updateChannel back to null (NULL preserved)', async () => { + const { id } = await createAgentConfig({ + projectId: 'test-project', + agentType: 'implementation', + updateChannel: 'both', + }); + + await updateAgentConfig(id, { updateChannel: null }); + + const configs = await listAgentConfigs({ projectId: 'test-project' }); + expect(configs[0].updateChannel).toBeNull(); + }); + + it('leaves updateChannel unchanged on a partial update that omits it', async () => { + const { id } = await createAgentConfig({ + projectId: 'test-project', + agentType: 'implementation', + updateChannel: 'pm-only', + }); + + await updateAgentConfig(id, { model: 'claude-opus-4-5' }); + + const configs = await listAgentConfigs({ projectId: 'test-project' }); + expect(configs[0].updateChannel).toBe('pm-only'); + expect(configs[0].model).toBe('claude-opus-4-5'); + }); + }); + // ========================================================================= // getAgentConfigPrompts // ========================================================================= diff --git a/tests/unit/api/routers/agentConfigs.test.ts b/tests/unit/api/routers/agentConfigs.test.ts index 92aecc4a0..496a5ed15 100644 --- a/tests/unit/api/routers/agentConfigs.test.ts +++ b/tests/unit/api/routers/agentConfigs.test.ts @@ -393,6 +393,122 @@ describe('agentConfigsRouter', () => { }); }); + describe('create with updateChannel', () => { + it('passes updateChannel to repository when creating project-scoped config', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockCreateAgentConfig.mockResolvedValue({ id: 40 }); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + await caller.create({ + projectId: 'proj-1', + agentType: 'review', + updateChannel: 'pm-only', + }); + + expect(mockCreateAgentConfig).toHaveBeenCalledWith( + expect.objectContaining({ updateChannel: 'pm-only' }), + ); + }); + + it('passes null updateChannel to repository when explicitly set to null', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockCreateAgentConfig.mockResolvedValue({ id: 41 }); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + await caller.create({ + projectId: 'proj-1', + agentType: 'review', + updateChannel: null, + }); + + expect(mockCreateAgentConfig).toHaveBeenCalledWith( + expect.objectContaining({ updateChannel: null }), + ); + }); + + it('omits updateChannel from repository call when not provided', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockCreateAgentConfig.mockResolvedValue({ id: 42 }); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + await caller.create({ + projectId: 'proj-1', + agentType: 'review', + }); + + const callArg = mockCreateAgentConfig.mock.calls[0][0]; + expect(Object.hasOwn(callArg, 'updateChannel')).toBe(false); + }); + + it('rejects an invalid updateChannel value', async () => { + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + await expect( + caller.create({ + projectId: 'proj-1', + agentType: 'review', + // @ts-expect-error: testing invalid enum value + updateChannel: 'invalid-channel', + }), + ).rejects.toThrow(); + + expect(mockCreateAgentConfig).not.toHaveBeenCalled(); + }); + }); + + describe('update with updateChannel', () => { + it('passes updateChannel to repository when updating project-scoped config', async () => { + mockDbWhere.mockResolvedValueOnce([{ projectId: 'proj-1' }]); + mockDbWhere.mockResolvedValueOnce([{ orgId: 'org-1' }]); + mockUpdateAgentConfig.mockResolvedValue(undefined); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + await caller.update({ id: 11, updateChannel: 'scm-only' }); + + expect(mockUpdateAgentConfig).toHaveBeenCalledWith( + 11, + expect.objectContaining({ updateChannel: 'scm-only' }), + ); + }); + + it('passes null updateChannel to repository when explicitly set to null', async () => { + mockDbWhere.mockResolvedValueOnce([{ projectId: 'proj-1' }]); + mockDbWhere.mockResolvedValueOnce([{ orgId: 'org-1' }]); + mockUpdateAgentConfig.mockResolvedValue(undefined); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + await caller.update({ id: 11, updateChannel: null }); + + expect(mockUpdateAgentConfig).toHaveBeenCalledWith( + 11, + expect.objectContaining({ updateChannel: null }), + ); + }); + + it('omits updateChannel from repository call when not provided', async () => { + mockDbWhere.mockResolvedValueOnce([{ projectId: 'proj-1' }]); + mockDbWhere.mockResolvedValueOnce([{ orgId: 'org-1' }]); + mockUpdateAgentConfig.mockResolvedValue(undefined); + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + await caller.update({ id: 11, model: 'new-model' }); + + const callArg = mockUpdateAgentConfig.mock.calls[0][1]; + expect(Object.hasOwn(callArg, 'updateChannel')).toBe(false); + }); + + it('rejects an invalid updateChannel value', async () => { + const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId }); + + await expect( + // @ts-expect-error: testing invalid enum value + caller.update({ id: 11, updateChannel: 'not-a-channel' }), + ).rejects.toThrow(); + + expect(mockUpdateAgentConfig).not.toHaveBeenCalled(); + }); + }); + describe('create with prompts', () => { it('passes systemPrompt and taskPrompt to repository when provided', async () => { mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); diff --git a/tests/unit/cli/dashboard/agents/agents.test.ts b/tests/unit/cli/dashboard/agents/agents.test.ts index 00f818efc..7f2b0a8f0 100644 --- a/tests/unit/cli/dashboard/agents/agents.test.ts +++ b/tests/unit/cli/dashboard/agents/agents.test.ts @@ -210,6 +210,35 @@ describe('AgentsCreate (create)', () => { ); }); + it('passes optional --update-channel flag to mutate', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new AgentsCreate( + ['--agent-type', 'review', '--project-id', 'my-project', '--update-channel', 'pm-only'], + oclifConfig as never, + ); + await cmd.run(); + + expect(client.agentConfigs.create.mutate).toHaveBeenCalledWith( + expect.objectContaining({ + agentType: 'review', + projectId: 'my-project', + updateChannel: 'pm-only', + }), + ); + }); + + it('rejects an invalid --update-channel value', async () => { + mockCreateDashboardClient.mockReturnValue(makeClient()); + + const cmd = new AgentsCreate( + ['--agent-type', 'review', '--project-id', 'my-project', '--update-channel', 'bogus'], + oclifConfig as never, + ); + await expect(cmd.run()).rejects.toThrow(); + }); + it('outputs json when --json flag is set', async () => { const client = makeClient(); mockCreateDashboardClient.mockReturnValue(client); @@ -315,6 +344,21 @@ describe('AgentsUpdate (update)', () => { ); }); + it('passes ID with --update-channel flag to mutate', async () => { + const client = makeClient(); + mockCreateDashboardClient.mockReturnValue(client); + + const cmd = new AgentsUpdate(['1', '--update-channel', 'scm-only'], oclifConfig as never); + await cmd.run(); + + expect(client.agentConfigs.update.mutate).toHaveBeenCalledWith( + expect.objectContaining({ + id: 1, + updateChannel: 'scm-only', + }), + ); + }); + it('requires ID argument', async () => { mockCreateDashboardClient.mockReturnValue(makeClient()); diff --git a/tests/unit/db/repositories/agentConfigsRepository.test.ts b/tests/unit/db/repositories/agentConfigsRepository.test.ts index b43d5b735..c8d86efa5 100644 --- a/tests/unit/db/repositories/agentConfigsRepository.test.ts +++ b/tests/unit/db/repositories/agentConfigsRepository.test.ts @@ -89,6 +89,23 @@ describe('agentConfigsRepository', () => { }), ); }); + + it('persists updateChannel when provided', async () => { + mockDb.chain.returning.mockResolvedValueOnce([{ id: 45 }]); + + const result = await createAgentConfig({ + projectId: 'proj-1', + agentType: 'implementation', + updateChannel: 'pm-only', + }); + + expect(result).toEqual({ id: 45 }); + expect(mockDb.chain.values).toHaveBeenCalledWith( + expect.objectContaining({ + updateChannel: 'pm-only', + }), + ); + }); }); describe('updateAgentConfig', () => { @@ -138,6 +155,26 @@ describe('agentConfigsRepository', () => { expect(setArg.taskPrompt).toBe('Updated task prompt.'); expect(setArg.updatedAt).toBeInstanceOf(Date); }); + + it('persists updateChannel when provided', async () => { + mockDb.chain.where.mockResolvedValueOnce(undefined); + + await updateAgentConfig(42, { updateChannel: 'scm-only' }); + + const setArg = mockDb.chain.set.mock.calls[0][0]; + expect(setArg.updateChannel).toBe('scm-only'); + expect(setArg.updatedAt).toBeInstanceOf(Date); + }); + + it('can set updateChannel to null', async () => { + mockDb.chain.where.mockResolvedValueOnce(undefined); + + await updateAgentConfig(42, { updateChannel: null }); + + const setArg = mockDb.chain.set.mock.calls[0][0]; + expect(setArg.updateChannel).toBeNull(); + expect(setArg.updatedAt).toBeInstanceOf(Date); + }); }); describe('deleteAgentConfig', () => { From c86f8306b65f4d43897413dcacb9437920f562e1 Mon Sep 17 00:00:00 2001 From: aaight Date: Thu, 25 Jun 2026 14:45:40 +0200 Subject: [PATCH 17/18] feat(dashboard): add update-channel selector to agent config form (#1450) Adds an "Update channel" Select (Both / SCM only / PM only / None) to the project agent config form's Engine tab, defaulting to `both`. Mirrors the existing maxConcurrency field 1:1: local state, config-sync useEffect, and save-handler mapping into both the create and update tRPC mutation inputs. - AgentConfig gains `updateChannel: UpdateChannel | null`; SaveConfigValues gains `updateChannel: string`. - Engine-tab Select wired to local state with a config-sync useEffect. - Save handler maps values.updateChannel into create + update inputs. - Adds a source-read regression test pinning the wiring. The backend tRPC write path (MNG-1683) already accepts updateChannel. Co-authored-by: Cascade Bot Co-authored-by: Claude Opus 4.8 --- .../web/agent-config-update-channel.test.ts | 99 +++++++++++++++++++ .../projects/agent-config-detail.tsx | 22 +++++ .../components/projects/agent-config-types.ts | 3 + .../projects/project-agent-configs.tsx | 7 ++ 4 files changed, 131 insertions(+) create mode 100644 tests/unit/web/agent-config-update-channel.test.ts diff --git a/tests/unit/web/agent-config-update-channel.test.ts b/tests/unit/web/agent-config-update-channel.test.ts new file mode 100644 index 000000000..80738f3c5 --- /dev/null +++ b/tests/unit/web/agent-config-update-channel.test.ts @@ -0,0 +1,99 @@ +/** + * Regression guard for the per-agent Update-channel selector (MNG-1686). + * + * `DefinitionAgentSection` is a hook-heavy JSX component (uses `useState`, + * `useEffect`, and Radix `Select`). It cannot be rendered as a plain function + * outside a React rendering context, and the unit environment has no jsdom. + * This test reads the source directly — the same source-read pattern used by + * `scm-webhook-secret-field.test.ts`, `combobox.test.ts`, and + * `pm-wizard-styling-guard.test.ts`. + * + * The backend (MNG-1683) already accepts `updateChannel` on the agentConfigs + * `create` / `update` tRPC mutations. This story is the UI-only selector that + * mirrors the existing `maxConcurrency` field wiring; these assertions pin that + * field's state / config-sync / save plumbing. + */ + +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const REPO_ROOT = resolve(__dirname, '..', '..', '..'); + +const PROJECTS_DIR = resolve(REPO_ROOT, 'web/src/components/projects'); +const typesSource = readFileSync(resolve(PROJECTS_DIR, 'agent-config-types.ts'), 'utf8'); +const detailSource = readFileSync(resolve(PROJECTS_DIR, 'agent-config-detail.tsx'), 'utf8'); +const configsSource = readFileSync(resolve(PROJECTS_DIR, 'project-agent-configs.tsx'), 'utf8'); + +describe('agent-config-types — updateChannel type fields', () => { + it('AgentConfig gains updateChannel: UpdateChannel | null', () => { + expect(typesSource).toContain('updateChannel: UpdateChannel | null;'); + }); + + it('SaveConfigValues gains updateChannel: string', () => { + expect(typesSource).toContain('updateChannel: string;'); + }); + + it('imports the UpdateChannel type from the shared config catalog (single source of truth)', () => { + expect(typesSource).toMatch( + /import type \{ UpdateChannel \} from '[^']*src\/config\/updateChannel\.js';/, + ); + }); +}); + +describe('agent-config-detail — Update channel Select wiring', () => { + it('declares local updateChannel state defaulting to "both"', () => { + expect(detailSource).toContain( + "const [updateChannel, setUpdateChannel] = useState(config?.updateChannel ?? 'both');", + ); + }); + + it('syncs updateChannel from config in the config-change effect (mirrors maxConcurrency)', () => { + expect(detailSource).toContain("setUpdateChannel(config?.updateChannel ?? 'both');"); + }); + + it('includes updateChannel in the save payload handed to onSaveConfig', () => { + const handleSaveStart = detailSource.indexOf('onSaveConfig(agentType, config?.id ?? null, {'); + expect(handleSaveStart, 'handleSave call must exist').toBeGreaterThan(-1); + const handleSaveBlock = detailSource.slice(handleSaveStart, handleSaveStart + 400); + expect(handleSaveBlock).toContain('updateChannel,'); + }); + + it('renders a Select bound to updateChannel state', () => { + expect(detailSource).toContain( + ' + + + + + Both + SCM only + PM only + None + + +

+ Where this agent posts communication-only status updates. Workflow actions (PRs, + status moves) always run. +

+ {/* Prompts Tab */} diff --git a/web/src/components/projects/agent-config-types.ts b/web/src/components/projects/agent-config-types.ts index ac8d4ebd6..bacbc2894 100644 --- a/web/src/components/projects/agent-config-types.ts +++ b/web/src/components/projects/agent-config-types.ts @@ -7,6 +7,7 @@ import type { ResolvedTrigger } from '@/components/shared/definition-trigger-toggles.js'; import type { TriggerParameterValue } from '@/lib/trigger-agent-mapping.js'; +import type { UpdateChannel } from '../../../../src/config/updateChannel.js'; export interface AgentConfig { id: number; @@ -18,6 +19,7 @@ export interface AgentConfig { maxConcurrency: number | null; systemPrompt: string | null; taskPrompt: string | null; + updateChannel: UpdateChannel | null; } interface EngineSettingFieldOption { @@ -59,6 +61,7 @@ export interface SaveConfigValues { maxIterations: string; agentEngine: string; maxConcurrency: string; + updateChannel: string; engineSettings: Record> | undefined; systemPrompt: string; taskPrompt: string; diff --git a/web/src/components/projects/project-agent-configs.tsx b/web/src/components/projects/project-agent-configs.tsx index 34d6787cc..f46c76be1 100644 --- a/web/src/components/projects/project-agent-configs.tsx +++ b/web/src/components/projects/project-agent-configs.tsx @@ -4,6 +4,7 @@ import { toast } from 'sonner'; import type { ResolvedTrigger } from '@/components/shared/definition-trigger-toggles.js'; import type { TriggerParameterValue } from '@/lib/trigger-agent-mapping.js'; import { trpc, trpcClient } from '@/lib/trpc.js'; +import type { UpdateChannel } from '../../../../src/config/updateChannel.js'; import { AgentDetailView } from './agent-config-detail.js'; import { AgentListView } from './agent-config-list.js'; import type { @@ -58,6 +59,7 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) { agentEngine: string | null; engineSettings: Record> | null; maxConcurrency: number | null; + updateChannel: UpdateChannel | null; systemPrompt: string | null; taskPrompt: string | null; }) => @@ -69,6 +71,7 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) { agentEngine: input.agentEngine, engineSettings: input.engineSettings, maxConcurrency: input.maxConcurrency, + updateChannel: input.updateChannel, systemPrompt: input.systemPrompt, taskPrompt: input.taskPrompt, }), @@ -95,6 +98,7 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) { agentEngine: string | null; engineSettings: Record> | null; maxConcurrency: number | null; + updateChannel: UpdateChannel | null; systemPrompt: string | null; taskPrompt: string | null; }) => @@ -106,6 +110,7 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) { agentEngine: input.agentEngine, engineSettings: input.engineSettings, maxConcurrency: input.maxConcurrency, + updateChannel: input.updateChannel, systemPrompt: input.systemPrompt, taskPrompt: input.taskPrompt, }), @@ -145,6 +150,7 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) { agentEngine: null, engineSettings: null, maxConcurrency: null, + updateChannel: null, systemPrompt: null, taskPrompt: null, }), @@ -247,6 +253,7 @@ export function ProjectAgentConfigs({ projectId }: { projectId: string }) { agentEngine: activeEngine, engineSettings: activeEngineSettings, maxConcurrency: values.maxConcurrency ? Number(values.maxConcurrency) : null, + updateChannel: values.updateChannel ? (values.updateChannel as UpdateChannel) : null, // When the user explicitly cleared an override, send null to remove it server-side. // Otherwise fall back to empty-string → null conversion for unpopulated fields. systemPrompt: values.systemPromptCleared ? null : values.systemPrompt || null, From 7e57870cb4f0ac6724c8bbb594cae9caf135a2a5 Mon Sep 17 00:00:00 2001 From: aaight Date: Thu, 25 Jun 2026 14:49:08 +0200 Subject: [PATCH 18/18] feat(web): add pure run-pending decision helper (#1451) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce web/src/lib/run-pending.ts, a node-testable decision helper that both run pages will consume to share consistent 'starting vs not-found' logic, plus its full unit-test suite. Foundational, pure logic only — no UI, API, or DB changes; no runtime consumers yet. Co-authored-by: Cascade Bot Co-authored-by: Claude Opus 4.8 --- tests/unit/web/run-pending.test.ts | 278 +++++++++++++++++++++++++++++ vitest.config.ts | 6 + web/src/lib/run-pending.ts | 187 +++++++++++++++++++ 3 files changed, 471 insertions(+) create mode 100644 tests/unit/web/run-pending.test.ts create mode 100644 web/src/lib/run-pending.ts diff --git a/tests/unit/web/run-pending.test.ts b/tests/unit/web/run-pending.test.ts new file mode 100644 index 000000000..f8f7e361c --- /dev/null +++ b/tests/unit/web/run-pending.test.ts @@ -0,0 +1,278 @@ +import { TRPCClientError } from '@trpc/client'; +import { describe, expect, it } from 'vitest'; + +import { + isNotFoundError, + RUN_PENDING_GRACE_MS, + RUN_PENDING_MAX_RETRIES, + RUN_PENDING_POLL_MS, + RUN_RUNNING_POLL_MS, + resolveRunDetailView, + resolveWorkItemRunsView, + workItemRunsRefetchInterval, +} from '../../../web/src/lib/run-pending.js'; + +/** + * Tests for the pure run-pending decision helper. + * + * Note: React component rendering tests are not possible in the current test + * setup (node environment, no jsdom). These tests cover the pure branching + * logic the run pages depend on, mirroring the style of + * tests/unit/web/work-item-runs.test.ts. + */ + +/** Build a TRPCClientError carrying a given tRPC error code in `data.code`. */ +function makeTRPCError(code: string, message = 'trpc error'): TRPCClientError { + const err = new TRPCClientError(message); + // The real client populates `data` from the server error envelope; mirror + // that shape here so the production `isTRPCClientError` guard sees it. + Object.assign(err, { data: { code } }); + return err; +} + +// ─── Exported constants ────────────────────────────────────────────────────── + +describe('run-pending constants', () => { + it('RUN_PENDING_POLL_MS is 3000', () => { + expect(RUN_PENDING_POLL_MS).toBe(3000); + }); + + it('RUN_PENDING_MAX_RETRIES is 20', () => { + expect(RUN_PENDING_MAX_RETRIES).toBe(20); + }); + + it('RUN_RUNNING_POLL_MS is 5000', () => { + expect(RUN_RUNNING_POLL_MS).toBe(5000); + }); + + it('grace window is poll * maxRetries (~60s)', () => { + expect(RUN_PENDING_GRACE_MS).toBe(RUN_PENDING_POLL_MS * RUN_PENDING_MAX_RETRIES); + expect(RUN_PENDING_GRACE_MS).toBe(60_000); + }); +}); + +// ─── isNotFoundError ───────────────────────────────────────────────────────── + +describe('isNotFoundError', () => { + it('returns true for a TRPCClientError whose data.code is NOT_FOUND', () => { + expect(isNotFoundError(makeTRPCError('NOT_FOUND'))).toBe(true); + }); + + it('returns false for BAD_REQUEST', () => { + expect(isNotFoundError(makeTRPCError('BAD_REQUEST'))).toBe(false); + }); + + it('returns false for FORBIDDEN', () => { + expect(isNotFoundError(makeTRPCError('FORBIDDEN'))).toBe(false); + }); + + it('returns false for UNAUTHORIZED', () => { + expect(isNotFoundError(makeTRPCError('UNAUTHORIZED'))).toBe(false); + }); + + it('returns false for a TRPCClientError with no data', () => { + expect(isNotFoundError(new TRPCClientError('boom'))).toBe(false); + }); + + it('returns false for a plain Error (even if its message says NOT_FOUND)', () => { + expect(isNotFoundError(new Error('NOT_FOUND'))).toBe(false); + }); + + it('returns false for null', () => { + expect(isNotFoundError(null)).toBe(false); + }); + + it('returns false for undefined', () => { + expect(isNotFoundError(undefined)).toBe(false); + }); + + it('returns false for a plain object that mimics the not-found shape', () => { + // Not a TRPCClientError instance — the guard must reject it. + expect(isNotFoundError({ data: { code: 'NOT_FOUND' } })).toBe(false); + }); +}); + +// ─── resolveRunDetailView ──────────────────────────────────────────────────── + +describe('resolveRunDetailView', () => { + const base: { + hasData: boolean; + isError: boolean; + error: unknown; + failureCount: number; + failureReason: unknown; + } = { + hasData: false, + isError: false, + error: null, + failureCount: 0, + failureReason: null, + }; + + it('returns ready when data is present', () => { + expect(resolveRunDetailView({ ...base, hasData: true })).toBe('ready'); + }); + + it('returns ready even if a background error is present alongside data', () => { + expect( + resolveRunDetailView({ + ...base, + hasData: true, + isError: true, + error: makeTRPCError('NOT_FOUND'), + }), + ).toBe('ready'); + }); + + it('returns loading on the first fetch (failureCount 0, no error)', () => { + expect(resolveRunDetailView(base)).toBe('loading'); + }); + + it('returns pending while retrying a NOT_FOUND within the retry ceiling', () => { + expect( + resolveRunDetailView({ + ...base, + failureCount: 3, + failureReason: makeTRPCError('NOT_FOUND'), + }), + ).toBe('pending'); + }); + + it('returns pending at the retry-ceiling boundary', () => { + expect( + resolveRunDetailView({ + ...base, + failureCount: RUN_PENDING_MAX_RETRIES, + failureReason: makeTRPCError('NOT_FOUND'), + }), + ).toBe('pending'); + }); + + it('returns not-found on a terminal NOT_FOUND error', () => { + expect( + resolveRunDetailView({ + ...base, + isError: true, + error: makeTRPCError('NOT_FOUND'), + failureCount: RUN_PENDING_MAX_RETRIES + 1, + failureReason: makeTRPCError('NOT_FOUND'), + }), + ).toBe('not-found'); + }); + + it('returns error on a terminal non-NOT_FOUND error', () => { + expect( + resolveRunDetailView({ + ...base, + isError: true, + error: makeTRPCError('INTERNAL_SERVER_ERROR'), + }), + ).toBe('error'); + }); + + it('returns error for a terminal plain Error', () => { + expect(resolveRunDetailView({ ...base, isError: true, error: new Error('network down') })).toBe( + 'error', + ); + }); + + it('falls back to loading while retrying a non-NOT_FOUND transient error', () => { + // A transient (non-NOT_FOUND) retry is not the "run not persisted yet" + // pending state — surface the generic loading state instead. + expect( + resolveRunDetailView({ + ...base, + failureCount: 2, + failureReason: makeTRPCError('INTERNAL_SERVER_ERROR'), + }), + ).toBe('loading'); + }); +}); + +// ─── resolveWorkItemRunsView ───────────────────────────────────────────────── + +describe('resolveWorkItemRunsView', () => { + const base = { + isLoading: false, + isError: false, + isEmpty: false, + elapsedMs: 0, + }; + + it('returns loading while isLoading', () => { + expect(resolveWorkItemRunsView({ ...base, isLoading: true })).toBe('loading'); + }); + + it('returns error when isError', () => { + expect(resolveWorkItemRunsView({ ...base, isError: true })).toBe('error'); + }); + + it('returns ready for a non-empty result', () => { + expect(resolveWorkItemRunsView({ ...base, isEmpty: false })).toBe('ready'); + }); + + it('returns pending for an empty result within the grace window', () => { + expect(resolveWorkItemRunsView({ ...base, isEmpty: true, elapsedMs: 1000 })).toBe('pending'); + }); + + it('returns empty for an empty result after the grace window', () => { + expect( + resolveWorkItemRunsView({ ...base, isEmpty: true, elapsedMs: RUN_PENDING_GRACE_MS + 1 }), + ).toBe('empty'); + }); + + it('treats the grace boundary as elapsed (empty exactly at grace)', () => { + expect( + resolveWorkItemRunsView({ ...base, isEmpty: true, elapsedMs: RUN_PENDING_GRACE_MS }), + ).toBe('empty'); + expect( + resolveWorkItemRunsView({ ...base, isEmpty: true, elapsedMs: RUN_PENDING_GRACE_MS - 1 }), + ).toBe('pending'); + }); + + it('prioritizes loading/error over the empty/pending split', () => { + expect( + resolveWorkItemRunsView({ ...base, isLoading: true, isEmpty: true, elapsedMs: 1000 }), + ).toBe('loading'); + expect( + resolveWorkItemRunsView({ ...base, isError: true, isEmpty: true, elapsedMs: 1000 }), + ).toBe('error'); + }); +}); + +// ─── workItemRunsRefetchInterval ───────────────────────────────────────────── + +describe('workItemRunsRefetchInterval', () => { + const base = { + hasRunning: false, + isEmpty: false, + elapsedMs: 0, + }; + + it('returns 5000 (RUN_RUNNING_POLL_MS) while a run is running', () => { + expect(workItemRunsRefetchInterval({ ...base, hasRunning: true })).toBe(5000); + expect(workItemRunsRefetchInterval({ ...base, hasRunning: true })).toBe(RUN_RUNNING_POLL_MS); + }); + + it('prioritizes the running cadence even within an empty grace window', () => { + expect(workItemRunsRefetchInterval({ hasRunning: true, isEmpty: true, elapsedMs: 1000 })).toBe( + RUN_RUNNING_POLL_MS, + ); + }); + + it('returns RUN_PENDING_POLL_MS while empty within the grace window', () => { + expect(workItemRunsRefetchInterval({ ...base, isEmpty: true, elapsedMs: 1000 })).toBe( + RUN_PENDING_POLL_MS, + ); + }); + + it('returns false once the grace window has elapsed', () => { + expect( + workItemRunsRefetchInterval({ ...base, isEmpty: true, elapsedMs: RUN_PENDING_GRACE_MS }), + ).toBe(false); + }); + + it('returns false when data is present (non-empty, nothing running)', () => { + expect(workItemRunsRefetchInterval({ ...base, isEmpty: false })).toBe(false); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 38b4568ec..fe0bafefa 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -13,6 +13,12 @@ const resolve = { }, { find: /^@\/lib\/(.*)/, replacement: path.resolve(__dirname, './web/src/lib/$1') }, { find: /^@\/hooks\/(.*)/, replacement: path.resolve(__dirname, './web/src/hooks/$1') }, + // Dedupe @trpc/client to a single copy. The web workspace has its own + // node_modules/@trpc/client, so without this a web/src file and a test + // file resolve different TRPCClientError classes and `instanceof` (used + // by isTRPCClientError) fails across copies. Mirrors the react/react-dom + // dedupe below. Must precede the catch-all `@` alias. + { find: /^@trpc\/client$/, replacement: path.resolve(__dirname, 'node_modules/@trpc/client') }, { find: '@', replacement: path.resolve(__dirname, './src') }, { find: 'react', replacement: path.resolve(__dirname, 'node_modules/react') }, { find: 'react-dom', replacement: path.resolve(__dirname, 'node_modules/react-dom') }, diff --git a/web/src/lib/run-pending.ts b/web/src/lib/run-pending.ts new file mode 100644 index 000000000..4404b3265 --- /dev/null +++ b/web/src/lib/run-pending.ts @@ -0,0 +1,187 @@ +import { isTRPCClientError } from '@trpc/client'; + +/** + * Pure decision helpers shared by the run-detail page (`/runs/$runId`) and the + * work-item / PR run-list pages (`/work-items/...`, `/prs/...`). + * + * A run row is materialized asynchronously by the worker pipeline, so right + * after a user navigates to a freshly-dispatched run the backend can still + * return `NOT_FOUND` (single run) or an empty list (run lists). Rather than + * flashing a misleading "not found" / "no runs yet" state, the pages poll for a + * short grace window and show a "starting…" pending state instead. + * + * All branching logic lives here — outside React — because the web test suite + * runs in a node environment with no jsdom, so the logic must be node-testable + * (mirrors the `computeSummaryStats` pattern in + * `tests/unit/web/work-item-runs.test.ts`). This module has no React imports and + * no side effects. + */ + +/** Poll interval (ms) while waiting for a not-yet-persisted run/list to appear. */ +export const RUN_PENDING_POLL_MS = 3000; + +/** + * Maximum number of NOT_FOUND retries for the single-run query before the page + * surfaces "not found". `RUN_PENDING_POLL_MS * RUN_PENDING_MAX_RETRIES` ≈ a + * 60-second grace window. + */ +export const RUN_PENDING_MAX_RETRIES = 20; + +/** Poll interval (ms) while at least one run is actively `running`. */ +export const RUN_RUNNING_POLL_MS = 5000; + +/** + * Grace window (ms) during which an empty run list is treated as "starting" + * rather than genuinely empty. Derived from the poll cadence and the retry + * ceiling so the ~60s window stays a single source of truth. + */ +export const RUN_PENDING_GRACE_MS = RUN_PENDING_POLL_MS * RUN_PENDING_MAX_RETRIES; + +/** + * True only when `error` is a tRPC client error whose `data.code` is + * `NOT_FOUND`. Returns `false` for every other tRPC code (`BAD_REQUEST`, + * `FORBIDDEN`, `UNAUTHORIZED`, …), a plain `Error`, `null`/`undefined`, or any + * non-error value — including a plain object that merely looks like a + * not-found shape. + */ +export function isNotFoundError(error: unknown): boolean { + if (!isTRPCClientError(error)) { + return false; + } + const code = (error.data as { code?: string } | null | undefined)?.code; + return code === 'NOT_FOUND'; +} + +/** True while `elapsedMs` is still inside the pending grace window. */ +function isWithinGrace(elapsedMs: number): boolean { + return elapsedMs < RUN_PENDING_GRACE_MS; +} + +// ─── Single-run detail view ────────────────────────────────────────────────── + +export type RunDetailView = 'loading' | 'pending' | 'not-found' | 'error' | 'ready'; + +export interface RunDetailViewInput { + /** True once the query has resolved a run object. */ + hasData: boolean; + /** True once the query has settled into a terminal error (retries exhausted). */ + isError: boolean; + /** The terminal error (populated when `isError` is true). */ + error: unknown; + /** Number of failed fetch attempts so far (`0` on the very first fetch). */ + failureCount: number; + /** Error from the most recent failed attempt (populated while retrying). */ + failureReason: unknown; +} + +/** + * Decides what the single-run page (`/runs/$runId`) should render. + * + * - `ready` — a run object is present (wins even over a later background error). + * - `not-found` — terminal error whose code is `NOT_FOUND` (the run never appeared). + * - `error` — terminal error with any other code / cause. + * - `pending` — actively retrying a `NOT_FOUND` within the retry ceiling + * (the run row most likely has not been persisted yet). + * - `loading` — first fetch in flight (no failures yet) or any other in-flight state. + */ +export function resolveRunDetailView({ + hasData, + isError, + error, + failureCount, + failureReason, +}: RunDetailViewInput): RunDetailView { + // Data present always wins, even if a later background refetch errors. + if (hasData) { + return 'ready'; + } + + // Terminal error: retries exhausted, or a non-retryable failure. + if (isError) { + return isNotFoundError(error) ? 'not-found' : 'error'; + } + + // Still fetching. Distinguish an active NOT_FOUND retry loop (run not yet + // persisted) from the very first in-flight fetch. + if ( + failureCount > 0 && + failureCount <= RUN_PENDING_MAX_RETRIES && + isNotFoundError(failureReason) + ) { + return 'pending'; + } + + return 'loading'; +} + +// ─── Run-list view (work-item + PR pages) ──────────────────────────────────── + +export type WorkItemRunsView = 'loading' | 'pending' | 'empty' | 'error' | 'ready'; + +export interface WorkItemRunsViewInput { + /** True while the first list fetch is in flight. */ + isLoading: boolean; + /** True when the list query errored. */ + isError: boolean; + /** True when the query resolved to zero runs. */ + isEmpty: boolean; + /** Time (ms) elapsed since the page began observing an empty result. */ + elapsedMs: number; +} + +/** + * Decides what the run-list pages (`/work-items/...`, `/prs/...`) should render. + * + * - `loading` — the initial fetch is in flight. + * - `error` — the query errored. + * - `ready` — at least one run is present. + * - `pending` — empty list but still inside the grace window (run starting). + * - `empty` — empty list after the grace window elapsed (genuinely no runs). + */ +export function resolveWorkItemRunsView({ + isLoading, + isError, + isEmpty, + elapsedMs, +}: WorkItemRunsViewInput): WorkItemRunsView { + if (isLoading) { + return 'loading'; + } + if (isError) { + return 'error'; + } + if (!isEmpty) { + return 'ready'; + } + return isWithinGrace(elapsedMs) ? 'pending' : 'empty'; +} + +export interface WorkItemRunsRefetchInput { + /** True when at least one run currently has status `running`. */ + hasRunning: boolean; + /** True when the query resolved to zero runs. */ + isEmpty: boolean; + /** Time (ms) elapsed since the page began observing an empty result. */ + elapsedMs: number; +} + +/** + * Refetch cadence for the run-list pages: + * + * - `RUN_RUNNING_POLL_MS` (5000) while any run is active. + * - `RUN_PENDING_POLL_MS` (3000) while the list is empty within the grace window. + * - `false` otherwise (terminal — stop polling). + */ +export function workItemRunsRefetchInterval({ + hasRunning, + isEmpty, + elapsedMs, +}: WorkItemRunsRefetchInput): number | false { + if (hasRunning) { + return RUN_RUNNING_POLL_MS; + } + if (isEmpty && isWithinGrace(elapsedMs)) { + return RUN_PENDING_POLL_MS; + } + return false; +}