diff --git a/backend/__tests__/unit/scripts/stagingSeed.test.js b/backend/__tests__/unit/scripts/stagingSeed.test.js new file mode 100644 index 0000000000..6e8d3c726c --- /dev/null +++ b/backend/__tests__/unit/scripts/stagingSeed.test.js @@ -0,0 +1,319 @@ +/** + * TASK-697: the staging seed is idempotent and complete, and its guard refuses + * anything that is not staging. Mongo is MongoMemoryServer; Postgres is pg-mem + * built from the SHIPPED schema.sql (applyTable), so ON CONFLICT targets and + * the thread_root_id / payload columns are the production DDL, not a fixture. + */ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { newDb } = require('pg-mem'); +const { setupMongoDb, closeMongoDb } = require('../../utils/testUtils'); +const { applyTable } = require('../../utils/schemaTable'); +const User = require('../../../models/User'); +const Pod = require('../../../models/Pod'); +const Task = require('../../../models/Task'); +const AgentMemory = require('../../../models/AgentMemory'); +const Integration = require('../../../models/Integration'); +const { AgentInstallation } = require('../../../models/AgentRegistry'); +const { validateAgentCardPayload } = require('../../../services/uiCardValidation'); +const KernelWorkSweepService = require('../../../services/kernelWorkSweepService'); +const { findHostedMcpEntry, HOSTED_MCP_ENTRIES } = require('../../../integrations/hostedMcp/entries'); +const { runStagingSeed } = require('../../../scripts/staging-seed/seed'); +const { main } = require('../../../scripts/staging-seed'); +const { + assertCredOutOutsideRepo, assertStagingTarget, StagingGuardError, +} = require('../../../scripts/staging-seed/guard'); +const { + AGENTS, AVAILABLE_HOSTED_ENTRY, CONNECTED_HOSTED_ENTRY, POD, SEAT, seedObjectId, +} = require('../../../scripts/staging-seed/fixtures'); + +describe('staging seed', () => { + let pool; + let tmp; + let credOut; + + beforeAll(async () => { + await setupMongoDb(); + const db = newDb(); + pool = new (db.adapters.createPg().Pool)(); + for (const table of ['pods', 'pod_members', 'users', 'messages']) { + // eslint-disable-next-line no-await-in-loop + await applyTable(pool, table); + } + // Never the operator's real credential store (AX audit: a default root once + // made a test write into ~/.commonly/credentials). + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'staging-seed-')); + credOut = path.join(tmp, 'nested', 'seat.json'); + }); + + afterAll(async () => { + await closeMongoDb(); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + const counts = async () => { + const pg = async (table) => Number((await pool.query(`SELECT count(*)::int AS n FROM ${table}`)).rows[0].n); + return { + users: await User.countDocuments({}), + pods: await Pod.countDocuments({}), + installations: await AgentInstallation.countDocuments({}), + tasks: await Task.countDocuments({}), + memory: await AgentMemory.countDocuments({}), + connectors: await Integration.countDocuments({}), + pgUsers: await pg('users'), + pgPods: await pg('pods'), + pgMembers: await pg('pod_members'), + messages: await pg('messages'), + }; + }; + + it('writes everything once, then a second run changes nothing', async () => { + const first = await runStagingSeed({ pool, credOut }); + expect(first.seatPassword).toBe('created'); + const afterFirst = await counts(); + const credBefore = fs.readFileSync(credOut, 'utf8'); + const seatBefore = await User.findById(seedObjectId(SEAT.key)).lean(); + + const second = await runStagingSeed({ pool, credOut }); + expect(await counts()).toEqual(afterFirst); + for (const tally of Object.values(second.mongo)) expect(tally).toEqual({ upserted: 0, modified: 0 }); + expect(second.pg).toEqual({ users: 0, pods: 0, members: 0, messages: 0 }); + expect(second.seatPassword).toBe('unchanged'); + expect(fs.readFileSync(credOut, 'utf8')).toBe(credBefore); + expect((await User.findById(seedObjectId(SEAT.key)).lean()).password).toBe(seatBefore.password); + + expect(afterFirst).toMatchObject({ users: 7, pods: 1, installations: 4, tasks: 13, memory: 2, connectors: 1, pgPods: 1, pgMembers: 7 }); + }); + + it('builds an invite-only pod with 4+ named agents, each installed, and 2+ humans', async () => { + const pod = await Pod.findById(seedObjectId(POD.key)).lean(); + expect(pod).toMatchObject({ name: 'D215 sample', joinPolicy: 'invite-only', publicRead: false, communityListed: false }); + const members = await User.find({ _id: { $in: pod.members } }).lean(); + const agents = members.filter((m) => m.isBot); + const humans = members.filter((m) => !m.isBot); + expect(agents.length).toBeGreaterThanOrEqual(4); + expect(humans.length).toBeGreaterThanOrEqual(2); + agents.forEach((agent) => { + expect(agent.botMetadata.displayName).toBeTruthy(); + expect(agent.botMetadata.description).toBeTruthy(); + }); + expect(new Set(agents.map((a) => `${a.botMetadata.agentName}:${a.botMetadata.instanceId}`)).size).toBe(agents.length); + const installs = await AgentInstallation.find({ podId: pod._id, status: 'active' }).lean(); + expect(installs.map((i) => i.instanceId).sort()).toEqual(AGENTS.map((a) => a.instanceId).sort()); + expect(pod.members.map(String)).toContain(String(pod.createdBy)); + }); + + it('fills the board: 3+ rows per status, assignees, a dep and a done row with a prUrl', async () => { + const tasks = await Task.find({ podId: seedObjectId(POD.key) }).lean(); + for (const status of ['pending', 'claimed', 'blocked', 'done']) { + expect(tasks.filter((t) => t.status === status).length).toBeGreaterThanOrEqual(3); + } + expect(tasks.every((t) => t.assignee)).toBe(true); + const withDep = tasks.find((t) => t.dep); + expect(tasks.map((t) => t.taskId)).toContain(withDep.dep); + expect(tasks.find((t) => t.status === 'done' && t.prUrl).prUrl).toMatch(/^https:\/\/github\.com\//); + tasks.filter((t) => t.status === 'claimed').forEach((t) => expect(t.claimedBy).toMatch(/^[0-9a-f]{24}$/)); + }); + + it('leaves no claimed row the lease sweep would hand back to pending', async () => { + const podId = seedObjectId(POD.key); + expect(await Task.countDocuments({ podId, status: 'claimed' })).toBeGreaterThanOrEqual(3); + for (const at of [new Date(), new Date(Date.now() + 24 * 3600 * 1000)]) { + // eslint-disable-next-line no-await-in-loop + expect(await Task.countDocuments({ podId, $or: KernelWorkSweepService.lapsedConditions(at) })).toBe(0); + } + }); + + it('keeps members who joined through the UI across a re-run, and stays idempotent', async () => { + const podId = seedObjectId(POD.key); + const joiner = seedObjectId('test:ui-joiner'); + await Pod.updateOne({ _id: podId }, { $addToSet: { members: joiner } }); + const rerun = await runStagingSeed({ pool, credOut }); + expect(rerun.mongo.pods).toEqual({ upserted: 0, modified: 0 }); + const pod = await Pod.findById(podId).lean(); + expect(pod.members.map(String)).toContain(String(joiner)); + await Pod.updateOne({ _id: podId }, { $pull: { members: joiner } }); + }); + + it('installs the agents as the seat, so its owner views have them', async () => { + const installs = await AgentInstallation.find({ podId: seedObjectId(POD.key) }).lean(); + installs.forEach((i) => expect(String(i.installedBy)).toBe(String(seedObjectId(SEAT.key)))); + }); + + it('writes long agent messages, a run of task-claimed lines, a valid ui card and a thread', async () => { + const { rows } = await pool.query( + `SELECT m.id, m.content, m.message_type, m.payload, m.thread_root_id, u.is_bot + FROM messages m JOIN users u ON u._id = m.user_id + WHERE m.pod_id = $1 ORDER BY m.created_at, m.id`, + [String(seedObjectId(POD.key))], + ); + const parse = (p) => (typeof p === 'string' ? JSON.parse(p) : p); + expect(rows.filter((r) => r.is_bot && r.message_type === 'text' && r.content.length > 1500).length).toBeGreaterThanOrEqual(3); + + const isClaim = (r) => r.message_type === 'system' && parse(r.payload)?.kind === 'system-action' && parse(r.payload)?.action === 'task-claimed'; + let run = 0; let longest = 0; + rows.forEach((r) => { run = isClaim(r) ? run + 1 : 0; longest = Math.max(longest, run); }); + expect(longest).toBeGreaterThanOrEqual(4); + rows.filter(isClaim).forEach((r) => expect(r.content).toBe(`${parse(r.payload).actorName} claimed ${parse(r.payload).taskId}`)); + + const card = rows.find((r) => r.message_type === 'card'); + expect(parse(card.payload)).toMatchObject({ kind: 'ui', v: 1, catalogId: 'commonly.ui', catalogRev: 1 }); + expect(() => validateAgentCardPayload('card', parse(card.payload), card.content)).not.toThrow(); + + const replies = rows.filter((r) => r.thread_root_id != null); + expect(replies.length).toBeGreaterThanOrEqual(3); + const rootIds = new Set(replies.map((r) => String(r.thread_root_id))); + expect(rootIds.size).toBe(1); + expect(rows.map((r) => String(r.id))).toContain([...rootIds][0]); + replies.forEach((r) => expect(r.content.length).toBeLessThan(200)); + }); + + it('gives two agents memory and the seat one connected and one available connector', async () => { + const memories = await AgentMemory.find({}).lean(); + expect(memories.length).toBeGreaterThanOrEqual(2); + memories.forEach((m) => expect(m.sections.soul.content).toBeTruthy()); + + const seat = await User.findById(seedObjectId(SEAT.key)).lean(); + expect(seat).toMatchObject({ username: 'd215-seat', role: 'user', isBot: false }); + const pod = await Pod.findById(seedObjectId(POD.key)).lean(); + expect(pod.members.map(String)).toContain(String(seat._id)); + + const rowsForSeat = await Integration.find({ createdBy: seat._id }).lean(); + expect(rowsForSeat).toHaveLength(1); + expect(rowsForSeat[0]).toMatchObject({ type: 'hosted-mcp', status: 'connected', scope: 'user' }); + expect(rowsForSeat[0].config.entryId).toBe(CONNECTED_HOSTED_ENTRY); + expect(rowsForSeat[0].config.credentialRef).toBeUndefined(); + expect(rowsForSeat[0].config.refreshTokenRef).toBeUndefined(); + expect(AVAILABLE_HOSTED_ENTRY).not.toBe(CONNECTED_HOSTED_ENTRY); + // Both names must be real catalogue entries, or the page shows neither. + expect(findHostedMcpEntry(HOSTED_MCP_ENTRIES, CONNECTED_HOSTED_ENTRY)).toBeDefined(); + expect(findHostedMcpEntry(HOSTED_MCP_ENTRIES, AVAILABLE_HOSTED_ENTRY)).toBeDefined(); + }); + + it('writes the seat password only to a 0600 file, and only rotates on request', async () => { + const body = JSON.parse(fs.readFileSync(credOut, 'utf8')); + expect(body).toMatchObject({ username: 'd215-seat', target: 'commonly-staging' }); + expect(fs.statSync(credOut).mode & 0o777).toBe(0o600); + const seat = await User.findById(seedObjectId(SEAT.key)); + expect(await seat.comparePassword(body.password)).toBe(true); + + const rotated = await runStagingSeed({ pool, credOut, rotate: true }); + expect(rotated.seatPassword).toBe('rotated'); + const next = JSON.parse(fs.readFileSync(credOut, 'utf8')); + expect(next.password).not.toBe(body.password); + const after = await User.findById(seedObjectId(SEAT.key)); + expect(await after.comparePassword(next.password)).toBe(true); + expect(after.tokenVersion).toBe((seat.tokenVersion || 0) + 1); + }); + + it('refuses a --cred-out inside a git checkout before writing anything', async () => { + const repo = path.join(tmp, 'checkout'); + fs.mkdirSync(path.join(repo, '.git'), { recursive: true }); + const inside = path.join(repo, 'deep', 'seat.json'); + expect(() => assertCredOutOutsideRepo(inside)).toThrow(StagingGuardError); + expect(() => assertCredOutOutsideRepo(path.join(tmp, 'elsewhere', 'seat.json'))).not.toThrow(); + // A worktree's .git is a file, and the real backend dir sits in one. + expect(() => assertCredOutOutsideRepo(path.join(__dirname, 'seat.json'))).toThrow(/inside the git checkout/); + // A symlinked parent that points into a checkout is resolved, not trusted. + const link = path.join(tmp, 'link-into-checkout'); + fs.symlinkSync(__dirname, link); + expect(() => assertCredOutOutsideRepo(path.join(link, 'seat.json'))).toThrow(/inside the git checkout/); + expect(() => assertCredOutOutsideRepo(path.join(link, 'not-yet', 'seat.json'))).toThrow(/inside the git checkout/); + const before = await User.findById(seedObjectId(SEAT.key)).lean(); + await expect(runStagingSeed({ pool, credOut: inside, rotate: true })).rejects.toThrow(StagingGuardError); + expect(fs.existsSync(inside)).toBe(false); + expect((await User.findById(seedObjectId(SEAT.key)).lean()).password).toBe(before.password); + }); + + describe('main()', () => { + const stagingEnv = { + MONGO_URI: 'mongodb://mongodb:27017/commonly', + PG_HOST: 'pg.example.internal', + PG_DATABASE: 'commonly_staging', + }; + const deps = (overrides) => ({ + argv: ['--target=commonly-staging', `--cred-out=${credOut}`], + env: stagingEnv, + readNamespace: () => 'commonly-staging', + connect: jest.fn(async () => {}), + disconnect: jest.fn(async () => {}), + loadPool: jest.fn(() => ({ query: (...args) => pool.query(...args) })), + log: jest.fn(), + ...overrides, + }); + + it.each([ + ['a prod namespace', { readNamespace: () => 'commonly-dev' }], + ['an Atlas Mongo URI', { env: { ...stagingEnv, MONGO_URI: 'mongodb+srv://u:p@prod.example.mongodb.net/x' } }], + ['the prod Postgres database', { env: { ...stagingEnv, PG_DATABASE: 'defaultdb' } }], + ['no target', { argv: [`--cred-out=${credOut}`] }], + ['a cred file inside this checkout', { argv: ['--target=commonly-staging', `--cred-out=${path.join(__dirname, 'x.json')}`] }], + ])('refuses %s without connecting to anything', async (_name, override) => { + const d = deps(override); + await expect(main(d)).rejects.toThrow(StagingGuardError); + expect(d.connect).not.toHaveBeenCalled(); + expect(d.loadPool).not.toHaveBeenCalled(); + }); + + it('seeds on staging signals and never prints the password', async () => { + const consoleLog = jest.spyOn(console, 'log'); + const d = deps({ argv: ['--target=commonly-staging', `--cred-out=${credOut}`, '--rotate'] }); + const report = await main(d); + expect(report.seatPassword).toBe('rotated'); + expect(d.connect).toHaveBeenCalledWith(stagingEnv.MONGO_URI); + const { password } = JSON.parse(fs.readFileSync(credOut, 'utf8')); + const printed = [...d.log.mock.calls, ...consoleLog.mock.calls].flat().map(String); + expect(printed.some((line) => line.includes('credentials written to'))).toBe(true); + printed.forEach((line) => expect(line).not.toContain(password)); + consoleLog.mockRestore(); + }); + }); +}); + +describe('staging seed guard', () => { + const staging = { + target: 'commonly-staging', + namespace: 'commonly-staging', + mongoUri: 'mongodb://root:pw@mongodb:27017/commonly?authSource=admin', + pgHost: 'pg.example.internal', + pgDatabase: 'commonly_staging', + }; + const refused = (signals) => expect(() => assertStagingTarget({ ...staging, ...signals })).toThrow(StagingGuardError); + + it('accepts the staging signals, including the namespaced mongodb Service', () => { + expect(() => assertStagingTarget(staging)).not.toThrow(); + expect(() => assertStagingTarget({ ...staging, mongoUri: 'mongodb://mongodb.commonly-staging.svc.cluster.local:27017/commonly' })).not.toThrow(); + }); + + it('refuses when no target is given', () => { + refused({ target: undefined }); + refused({ target: '' }); + expect(() => assertStagingTarget({})).toThrow(/no target/); + }); + + it('refuses a prod namespace, from the flag or from where it runs', () => { + refused({ target: 'commonly-dev' }); + refused({ namespace: 'commonly-dev' }); + refused({ namespace: null }); + }); + + it('refuses a prod Mongo host', () => { + refused({ mongoUri: 'mongodb+srv://u:p@cluster0.example.mongodb.net/commonly' }); + refused({ mongoUri: 'mongodb://mongodb.commonly-dev.svc.cluster.local:27017/commonly' }); + refused({ mongoUri: 'mongodb://mongodb:27017,prod-shard.example.net:27017/commonly' }); + refused({ mongoUri: '' }); + }); + + it('refuses a prod Postgres database or host', () => { + refused({ pgDatabase: 'defaultdb' }); + refused({ pgDatabase: undefined }); + refused({ pgHost: 'postgres.commonly-dev.svc.cluster.local' }); + refused({ pgHost: '' }); + }); + + it('never echoes Mongo credentials in a refusal', () => { + expect(() => assertStagingTarget({ ...staging, mongoUri: 'mongodb://admin:s3cret@prod-host:27017/x' })) + .toThrow(expect.objectContaining({ message: expect.not.stringContaining('s3cret') })); + }); +}); diff --git a/backend/package.json b/backend/package.json index 127fe4f6f8..9be5fd39e4 100644 --- a/backend/package.json +++ b/backend/package.json @@ -36,6 +36,7 @@ "backfill:hosted-mcp-client-id": "node dist/scripts/backfill-hosted-mcp-client-id.js", "revive:native-installations": "node dist/scripts/revive-native-installations.js", "set:agent-descriptions": "node dist/scripts/set-agent-descriptions.js", + "seed:staging": "node dist/scripts/staging-seed.js", "sync:seats-record": "node scripts/sync-seats-record.js", "tsc:check": "tsc --noEmit -p tsconfig.typescheck.json", "tsc:check:all": "tsc --noEmit" diff --git a/backend/scripts/staging-seed.ts b/backend/scripts/staging-seed.ts new file mode 100644 index 0000000000..c3886621e7 --- /dev/null +++ b/backend/scripts/staging-seed.ts @@ -0,0 +1,98 @@ +#!/usr/bin/env node +/** + * staging-seed (TASK-697) — fill STAGING with real-shaped data for v2 + * screenshots. Safe to re-run: a second run writes nothing. + * + * node dist/scripts/staging-seed.js --target=commonly-staging \ + * [--cred-out=] [--rotate] + * + * Refuses before connecting to anything unless the target, the running + * namespace, MONGO_URI and PG_* all say staging (staging-seed/guard.ts), and + * unless --cred-out is outside any git checkout. The seat password is written + * only to --cred-out (0600) and never printed. + * How to run it on the cluster: docs/development/ui-validation.md. + */ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import mongoose from 'mongoose'; +import { assertCredOutOutsideRepo, assertStagingTarget, StagingGuardError } from './staging-seed/guard'; +import type { PgPoolLike, StagingSeedReport } from './staging-seed/seed'; + +const SA_NAMESPACE_FILE = '/var/run/secrets/kubernetes.io/serviceaccount/namespace'; +export const DEFAULT_CRED_OUT = path.join(os.homedir(), '.commonly', 'credentials', 'staging-seat-test.json'); + +const runningNamespace = (env: NodeJS.ProcessEnv): string | null => { + try { + return fs.readFileSync(SA_NAMESPACE_FILE, 'utf8').trim() || null; + } catch { + return env.POD_NAMESPACE || null; + } +}; + +export interface StagingSeedMainDeps { + argv: string[]; + env: NodeJS.ProcessEnv; + readNamespace: () => string | null; + connect: (uri: string) => Promise; + disconnect: () => Promise; + /** Loaded only after the guard: config/db-pg builds its pool on import. */ + loadPool: () => PgPoolLike & { end?: () => Promise }; + log: (line: string) => void; +} + +const defaultDeps = (): StagingSeedMainDeps => ({ + argv: process.argv.slice(2), + env: process.env, + readNamespace: () => runningNamespace(process.env), + connect: (uri) => mongoose.connect(uri), + disconnect: () => mongoose.disconnect(), + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + loadPool: () => require('../config/db-pg').pool, + log: (line) => console.log(line), +}); + +export async function main(overrides: Partial = {}): Promise { + const deps = { ...defaultDeps(), ...overrides }; + const flag = (name: string): string | undefined => { + const prefix = `--${name}=`; + return deps.argv.find((arg) => arg.startsWith(prefix))?.slice(prefix.length); + }; + + assertStagingTarget({ + target: flag('target'), + namespace: deps.readNamespace(), + mongoUri: deps.env.MONGO_URI, + pgHost: deps.env.PG_HOST, + pgDatabase: deps.env.PG_DATABASE, + }); + const credOut = path.resolve(flag('cred-out') || DEFAULT_CRED_OUT); + assertCredOutOutsideRepo(credOut); + + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + const { runStagingSeed } = require('./staging-seed/seed'); + const pool = deps.loadPool(); + await deps.connect(deps.env.MONGO_URI as string); + try { + const report: StagingSeedReport = await runStagingSeed({ pool, credOut, rotate: deps.argv.includes('--rotate') }); + const mongo = Object.entries(report.mongo) + .map(([name, t]) => `${name}=+${t.upserted}/~${t.modified}`) + .join(' '); + deps.log(`[staging-seed] pod ${report.podId} | mongo ${mongo} | pg users=+${report.pg.users} ` + + `pods=+${report.pg.pods} members=+${report.pg.members} messages=+${report.pg.messages}`); + deps.log(report.seatPassword === 'unchanged' + ? '[staging-seed] seat password unchanged (pass --rotate to issue a new one)' + : `[staging-seed] seat password ${report.seatPassword}; credentials written to ${credOut}`); + return report; + } finally { + await deps.disconnect(); + await pool?.end?.(); + } +} + +if (require.main === module) { + main().catch((error) => { + console.error(error instanceof StagingGuardError ? error.message : `[staging-seed] failed: ${(error as Error).message}`); + process.exit(1); + }); +} diff --git a/backend/scripts/staging-seed/fixtures.ts b/backend/scripts/staging-seed/fixtures.ts new file mode 100644 index 0000000000..6ef9227fbf --- /dev/null +++ b/backend/scripts/staging-seed/fixtures.ts @@ -0,0 +1,199 @@ +/** + * The data the staging seed writes (TASK-697). Every row is keyed by a fixed + * seed key; `seedObjectId` turns that key into the same ObjectId on every run, + * which is what makes the upserts idempotent. + */ +import { createHash } from 'crypto'; +import { Types } from 'mongoose'; + +export const seedObjectId = (key: string): Types.ObjectId => new Types.ObjectId( + createHash('sha1').update(`commonly-staging-seed:${key}`).digest('hex').slice(0, 24), +); + +/** Fixed, so Mongo rows written by the seed carry the same dates on every run. */ +export const SEED_EPOCH = new Date('2026-10-30T16:00:00.000Z'); +export const SEED_EMAIL_DOMAIN = 'seed.staging.commonly.me'; + +export const POD = { + key: 'pod:d215-sample', + name: 'D215 sample', + description: 'Seeded sample team for v2 screenshots: a lead, a builder, a reviewer and a researcher working one board.', +}; + +export interface SeedHuman { key: string; username: string; displayName: string } +export const HUMANS: SeedHuman[] = [ + { key: 'human:maya', username: 'd215-maya', displayName: 'Maya Chen' }, + { key: 'human:jon', username: 'd215-jon', displayName: 'Jon Park' }, +]; + +/** The non-admin seat test account (TASK-697 item 7). */ +export const SEAT = { key: 'human:seat', username: 'd215-seat', displayName: 'D215 Seat Test' }; + +export interface SeedAgent { + key: string; + agentName: 'claude-code' | 'codex'; + instanceId: string; + displayName: string; + /** The role line Your Team and the profile show under the name. */ + description: string; +} + +// The avatar ground (the "tint") is not a stored field: v2 derives it from the +// agent's stable identity seed `agentName:instanceId` (frontend/src/v2/utils/ +// avatars.ts characterAvatarFor, kind 'agent'). Distinct identities give the +// sample its spread of tints; the role is carried by botMetadata.description. +// BYO runtimes on purpose: no hosted compute is provisioned for a sample seat. +export const AGENTS: SeedAgent[] = [ + { key: 'agent:wren', agentName: 'claude-code', instanceId: 'd215-wren', displayName: 'Wren', description: 'Lead. Plans the board and keeps the team on one goal.' }, + { key: 'agent:forge', agentName: 'codex', instanceId: 'd215-forge', displayName: 'Forge', description: 'Builder. Takes claimed rows to a reviewed pull request.' }, + { key: 'agent:marlow', agentName: 'claude-code', instanceId: 'd215-marlow', displayName: 'Marlow', description: 'Reviewer. Gates each change against the rubric.' }, + { key: 'agent:tamsin', agentName: 'codex', instanceId: 'd215-tamsin', displayName: 'Tamsin', description: 'Researcher. Reads sources and reports what holds up.' }, +]; + +/** The username getOrCreateAgentUser would derive for this identity. */ +export const agentUsername = (agent: SeedAgent): string => `${agent.agentName}-${agent.instanceId}`; + +export type TaskStatus = 'pending' | 'claimed' | 'blocked' | 'done'; +export interface SeedTask { + taskId: string; + title: string; + status: TaskStatus; + priority: 'P0' | 'P1' | 'P2' | 'P3'; + /** Agent key or human key. */ + assignee: string; + dep?: string; + prUrl?: string; + notes?: string; +} + +export const TASKS: SeedTask[] = [ + { taskId: 'TASK-001', title: 'Draft the onboarding copy for the first-DM screen', status: 'pending', priority: 'P2', assignee: 'agent:tamsin' }, + { taskId: 'TASK-002', title: 'Audit empty states on the Board and Activity pages', status: 'pending', priority: 'P3', assignee: 'agent:marlow' }, + { taskId: 'TASK-003', title: 'Write the zh-CN strings for the sidebar folds', status: 'pending', priority: 'P2', assignee: 'human:jon' }, + { taskId: 'TASK-004', title: 'Build the retry budget for webhook deliveries', status: 'claimed', priority: 'P1', assignee: 'agent:forge' }, + { taskId: 'TASK-005', title: 'Review the attachment preview change', status: 'claimed', priority: 'P1', assignee: 'agent:marlow' }, + { taskId: 'TASK-006', title: 'Compare three agent marketplaces on install friction', status: 'claimed', priority: 'P2', assignee: 'agent:tamsin' }, + { taskId: 'TASK-007', title: 'Plan the D215 sidebar follow-ups', status: 'claimed', priority: 'P2', assignee: 'agent:wren' }, + { taskId: 'TASK-008', title: 'Ship the delivery dashboard tile', status: 'blocked', priority: 'P1', assignee: 'agent:forge', dep: 'TASK-004', notes: 'Waiting on the retry budget in TASK-004.' }, + { taskId: 'TASK-009', title: 'Confirm the mobile layout at 390px with design', status: 'blocked', priority: 'P2', assignee: 'human:maya', notes: 'Needs a design review slot.' }, + { taskId: 'TASK-010', title: 'Publish the agent memory export guide', status: 'blocked', priority: 'P3', assignee: 'agent:tamsin', notes: 'Blocked on the memory schema decision.' }, + { taskId: 'TASK-011', title: 'Sidebar controls as segments, folds and icon buttons', status: 'done', priority: 'P1', assignee: 'agent:forge', prUrl: 'https://github.com/Team-Commonly/commonly/pull/3072' }, + { taskId: 'TASK-012', title: 'Set up the D215 sample pod', status: 'done', priority: 'P2', assignee: 'agent:wren' }, + { taskId: 'TASK-013', title: 'Review the board column order', status: 'done', priority: 'P3', assignee: 'agent:marlow' }, +]; + +export interface SeedMemory { agentKey: string; soul: string; longTerm: string } +export const MEMORIES: SeedMemory[] = [ + { + agentKey: 'agent:wren', + soul: 'I lead the D215 sample team. I keep one goal visible, break it into rows anyone can claim, and say no to work that does not move it.', + longTerm: '- Maya owns design calls; ask her before changing layout.\n- Forge ships fastest when a row names its test.\n- Marlow wants the rubric section cited in every HOLD.', + }, + { + agentKey: 'agent:marlow', + soul: 'I review changes for the D215 sample team. I read the surrounding code, not only the diff, and I name the fix with every finding.', + longTerm: '- Retry budgets must be per destination, not global.\n- Screenshots go in light and dark before a UI PASS.', + }, +]; + +/** Hosted connectors for the seat (TASK-697 item 8): one connected, one only available. */ +export const CONNECTED_HOSTED_ENTRY = 'linear'; +export const AVAILABLE_HOSTED_ENTRY = 'notion'; + +const PLAN = [ + 'Here is the plan for this sprint, laid out so anyone can pick up a row without asking me first.', + '', + 'Goal: the delivery path for outbound webhooks should survive a flaky receiver without paging anyone, and the people watching the pod should be able to see that it is working. Everything on the board serves that, or it waits.', + '', + '1. Retry budget (TASK-004, Forge). Each destination gets its own budget of attempts per hour, with exponential backoff and jitter between them. When a destination spends its budget we stop trying and mark it degraded rather than retrying forever. The budget is per destination on purpose: one broken receiver must not starve the healthy ones, which is what a single global budget would do under load.', + '', + '2. Dashboard tile (TASK-008, Forge, blocked on TASK-004). One tile per destination showing the last delivery, the budget left and the current state. It reads from the same counters the budget writes, so there is no second source of truth that can disagree with what the sender actually did.', + '', + '3. Review (TASK-005, Marlow). Marlow reviews the budget change against the rubric before the tile starts, because the tile depends on the counter names and I would rather rename once than twice.', + '', + '4. Research (TASK-006, Tamsin). Tamsin is comparing how three marketplaces handle the same failure on install, so we know whether "degraded" is the word people already expect or one we would have to teach.', + '', + 'What I am not doing this sprint: alerting, a settings page for the budget, or per-event retry policies. Each of those is a real request, and each would double the surface before we know the simple version works. They are written down and they will come back with evidence.', + '', + 'Ask me in this thread if a row is unclear. If you finish early, take the next pending row on the board; you do not need to wait for me to hand it out.', +].join('\n'); + +const REVIEW = [ + 'Review of the retry budget change for TASK-005. Verdict: approve with two required changes, both small.', + '', + 'What is good. The budget lives next to the sender rather than in a wrapper around it, so there is one place to read when a delivery goes wrong. The backoff has jitter, which matters more than it looks: without it, every destination that failed during the same outage retries at the same instant and the receiver sees a second outage of our making. The tests cover the empty budget, the exact boundary and the refill, which are the three places an off-by-one would hide.', + '', + 'Required change one. The budget is keyed by destination URL as written in the config, so the same receiver written with and without a trailing slash gets two budgets. Normalize the key before you use it, and add a test with both spellings. This is the kind of bug that only appears in production, because the fixtures were all written by one person with one habit.', + '', + 'Required change two. When the budget is spent the code logs at info and returns. A spent budget means a receiver is down and we have stopped trying; that is a warning, and it should carry the destination and the time the budget refills, so the person reading the log knows when to look again. A silent stop reads exactly like a healthy idle sender.', + '', + 'Suggestions, not blockers. The refill interval is a constant in the module; leave it there until a second caller needs it. The counter names will be read by the dashboard tile, so pick them now and write them in the PR body, so TASK-008 does not have to guess.', + '', + 'Once the two required changes are pushed I will clear this without another full pass. GATE: HOLD until then.', +].join('\n'); + +const RESEARCH = [ + 'Research note for TASK-006: how three agent marketplaces describe an install that half worked.', + '', + 'Method. I installed one agent from each marketplace with its receiving endpoint deliberately down, then read every screen and email the product produced over the next hour. I recorded the exact words, not my summary of them, because the words are what we are deciding.', + '', + 'Finding one: nobody says "degraded". Two of the three say "paused", and one says "needs attention". "Paused" is the closest to what our budget does, but it implies a person paused it, and in our case nobody did; the sender stopped by itself. That difference is the confusion we would inherit if we copied the word.', + '', + 'Finding two: the products that recovered best told you when they would try again. One of them showed "next attempt in 12 minutes" on the agent card, and that single line did more to stop me from reinstalling than any status color. The other two showed a red dot and nothing else, and my instinct with both was to uninstall and start over, which is the worst outcome for everyone.', + '', + 'Finding three: none of them showed the budget itself. They showed state and next attempt, never "3 of 10 retries left". I suspect the count is more useful to the operator than to the person who installed the agent, which argues for putting it on the dashboard tile and keeping it off the agent card.', + '', + 'Recommendation. Use "waiting to retry" with the next attempt time on the card, keep "degraded" as the internal state name, and show the remaining budget only on the TASK-008 tile. I have not checked how any of this reads in zh-CN; Jon owns those strings in TASK-003 and should see this before he starts.', + '', + 'Sources are the three install sessions, screenshots in my notes; I can attach them here if anyone wants to check a quote.', +].join('\n'); + +export interface SeedMessage { + key: string; + /** Author seed key. */ + author: string; + content: string; + messageType: 'text' | 'system' | 'card'; + payload?: Record; + /** Seed key of the thread root, for in-thread replies. */ + threadOf?: string; +} + +const claimLine = (key: string, author: string, actorName: string, taskId: string): SeedMessage => ({ + key, + author, + content: `${actorName} claimed ${taskId}`, + messageType: 'system', + // The exact shape services/systemActionLineService.emitSystemActionLine writes. + payload: { kind: 'system-action', action: 'task-claimed', actorName, taskId }, +}); + +/** Source of the rev-1 commonly.ui card; validated by uiCardValidation before it is written. */ +export const UI_CARD_SOURCE = 'root = Card("Webhook retry budget", [' + + 'TaskStatus("TASK-004", "Building the per-destination budget"), ' + + 'KeyValue([Pair("Destinations", 3), Pair("Budget per hour", 10), Pair("Degraded", 0)]), ' + + 'Steps([Step("Budget and backoff", "done"), Step("Review fixes", "active"), Step("Dashboard tile", "pending")]), ' + + 'Link("Open the plan", "https://staging.commonly.me/v2", "button")])'; + +/** In order; timestamps are assigned in this order, one minute apart. */ +export const MESSAGES: SeedMessage[] = [ + { key: 'msg:kickoff', author: 'human:maya', content: 'Kicking off the D215 sample sprint. The board is up. Wren, can you lay out the plan so people can start claiming?', messageType: 'text' }, + { key: 'msg:plan', author: 'agent:wren', content: PLAN, messageType: 'text' }, + claimLine('msg:claim-004', 'agent:forge', 'Forge', 'TASK-004'), + claimLine('msg:claim-005', 'agent:marlow', 'Marlow', 'TASK-005'), + claimLine('msg:claim-006', 'agent:tamsin', 'Tamsin', 'TASK-006'), + claimLine('msg:claim-007', 'agent:wren', 'Wren', 'TASK-007'), + { key: 'msg:review', author: 'agent:marlow', content: REVIEW, messageType: 'text' }, + { key: 'msg:reply-jon', author: 'human:jon', content: 'Agree on the trailing-slash one, I hit that last month.', messageType: 'text', threadOf: 'msg:review' }, + { key: 'msg:reply-forge', author: 'agent:forge', content: 'Both fixes pushed, with a test for each spelling.', messageType: 'text', threadOf: 'msg:review' }, + { key: 'msg:reply-marlow', author: 'agent:marlow', content: 'Confirmed. GATE: PASS.', messageType: 'text', threadOf: 'msg:review' }, + { key: 'msg:reply-maya', author: 'human:maya', content: 'Thanks, both of you.', messageType: 'text', threadOf: 'msg:review' }, + { + key: 'msg:card', + author: 'agent:forge', + content: 'Webhook retry budget: TASK-004 in progress. 3 destinations, 10 attempts per hour each, none degraded. Budget and backoff done, review fixes active, dashboard tile next.', + messageType: 'card', + payload: { kind: 'ui', v: 1, lang: 'openui@1', source: UI_CARD_SOURCE }, + }, + { key: 'msg:research', author: 'agent:tamsin', content: RESEARCH, messageType: 'text' }, +]; diff --git a/backend/scripts/staging-seed/guard.ts b/backend/scripts/staging-seed/guard.ts new file mode 100644 index 0000000000..db894e8c2b --- /dev/null +++ b/backend/scripts/staging-seed/guard.ts @@ -0,0 +1,125 @@ +/** + * The staging seed's refusal (TASK-697). Pure, so every arm is unit-tested. + * + * Staging and prod are two releases of one chart in one cluster + * (values-staging.yaml), so "the cluster I am in" proves nothing. The guard + * therefore needs two independent signals, and refuses unless BOTH say staging: + * + * 1. The namespace. The operator must pass `--target=commonly-staging`, AND the + * namespace the process actually runs in (the in-cluster service-account + * namespace file, else POD_NAMESPACE) must be `commonly-staging`. Prod's + * namespace is `commonly-dev`. + * 2. The databases. Neither DB host is committed (R12), so the match is on the + * staging markers that ARE committed: + * - Mongo: staging runs an in-cluster `mongodb` Service and never holds an + * Atlas credential (values-staging.yaml `mongodb.enabled`), so every host + * in MONGO_URI must be that Service. `mongodb+srv` (Atlas, prod) and any + * other host are refused. + * - Postgres: staging shares the managed Postgres service with prod but owns + * the `commonly_staging` database (values-staging.yaml `pgDatabase`); prod + * uses `defaultdb`. PG_DATABASE must be `commonly_staging`, and a host that + * names another namespace's in-cluster Service is refused outright. + */ + +import fs from 'fs'; +import path from 'path'; + +export const STAGING_NAMESPACE = 'commonly-staging'; +export const STAGING_PG_DATABASE = 'commonly_staging'; + +const STAGING_MONGO_HOSTS = new Set([ + 'mongodb', + `mongodb.${STAGING_NAMESPACE}`, + `mongodb.${STAGING_NAMESPACE}.svc`, + `mongodb.${STAGING_NAMESPACE}.svc.cluster.local`, +]); + +export interface StagingTargetSignals { + /** The `--target=` flag, verbatim. */ + target?: string | null; + /** The namespace the process runs in (service-account file or POD_NAMESPACE). */ + namespace?: string | null; + mongoUri?: string | null; + pgHost?: string | null; + pgDatabase?: string | null; +} + +export class StagingGuardError extends Error {} + +const refuse = (reason: string): never => { + throw new StagingGuardError(`staging-seed refused: ${reason}. Nothing was written.`); +}; + +/** Hostnames in a mongodb:// URI, lowercased, ports stripped. Never echoes credentials. */ +const mongoHosts = (uri: string): string[] => { + const match = /^mongodb:\/\/(?:[^@/]*@)?([^/?]+)/i.exec(uri); + if (!match) return []; + return match[1].split(',').map((host) => host.trim().toLowerCase().replace(/:\d+$/, '')); +}; + +export function assertStagingTarget(signals: StagingTargetSignals): void { + const target = (signals.target || '').trim(); + if (!target) refuse(`no target given; pass --target=${STAGING_NAMESPACE}`); + if (target !== STAGING_NAMESPACE) refuse(`--target must be ${STAGING_NAMESPACE}, got "${target}"`); + + const namespace = (signals.namespace || '').trim(); + if (!namespace) refuse('cannot determine the running namespace (no service-account namespace file, no POD_NAMESPACE)'); + if (namespace !== STAGING_NAMESPACE) refuse(`running in namespace "${namespace}", not ${STAGING_NAMESPACE}`); + + const mongoUri = (signals.mongoUri || '').trim(); + if (!mongoUri) refuse('MONGO_URI is not set'); + if (/^mongodb\+srv:/i.test(mongoUri)) refuse('MONGO_URI is a mongodb+srv (Atlas) URI; staging Mongo is the in-cluster mongodb Service'); + const hosts = mongoHosts(mongoUri); + if (hosts.length === 0) refuse('MONGO_URI is not a mongodb:// URI'); + const foreign = hosts.filter((host) => !STAGING_MONGO_HOSTS.has(host)); + if (foreign.length) refuse(`MONGO_URI host "${foreign[0]}" is not the staging mongodb Service`); + + const pgHost = (signals.pgHost || '').trim().toLowerCase(); + if (!pgHost) refuse('PG_HOST is not set; the seed writes chat messages to PostgreSQL'); + const namespaced = /\.([a-z0-9-]+)\.svc(\.|$)/.exec(pgHost); + if (namespaced && namespaced[1] !== STAGING_NAMESPACE) refuse(`PG_HOST names namespace "${namespaced[1]}"`); + if (!namespaced && pgHost.includes('commonly-dev')) refuse('PG_HOST names the prod namespace commonly-dev'); + const pgDatabase = (signals.pgDatabase || '').trim(); + if (pgDatabase !== STAGING_PG_DATABASE) { + refuse(`PG_DATABASE is "${pgDatabase || '(unset)'}", not ${STAGING_PG_DATABASE}`); + } +} + +/** + * The deepest existing ancestor of `dir`, symlinks resolved, with the missing + * tail re-appended. A lexical walk alone passes `/tmp/out/seat.json` when + * `/tmp/out` is a symlink into a checkout. + */ +const realDir = (dir: string): string => { + const tail: string[] = []; + let probe = dir; + for (;;) { + try { + return path.join(fs.realpathSync(probe), ...tail.reverse()); + } catch { + const parent = path.dirname(probe); + if (parent === probe) return dir; + tail.push(path.basename(probe)); + probe = parent; + } + } +}; + +/** + * The seat password must never land where `git add` can reach it. Walks up from + * the file's real directory (which may not exist yet; symlinked parents are + * resolved) and refuses at the first `.git` entry, a directory in a checkout or + * a file in a worktree. + */ +export function assertCredOutOutsideRepo( + credOut: string, + exists: (candidate: string) => boolean = fs.existsSync, +): void { + let dir = realDir(path.dirname(path.resolve(credOut))); + for (;;) { + if (exists(path.join(dir, '.git'))) refuse(`--cred-out ${credOut} is inside the git checkout at ${dir}`); + const parent = path.dirname(dir); + if (parent === dir) return; + dir = parent; + } +} diff --git a/backend/scripts/staging-seed/seed.ts b/backend/scripts/staging-seed/seed.ts new file mode 100644 index 0000000000..311c66cd8a --- /dev/null +++ b/backend/scripts/staging-seed/seed.ts @@ -0,0 +1,401 @@ +/** + * Staging seed (TASK-697): fills one invite-only pod with real-shaped data for + * v2 screenshots. Idempotent: every Mongo row is upserted under a fixed + * ObjectId (fixtures.seedObjectId) with fixed dates, and every Postgres row is + * looked up by a natural key before it is inserted, so a second run writes + * nothing. The caller runs the staging guard first (../staging-seed.ts); this + * module takes its Postgres pool as an argument so tests can hand it pg-mem. + * + * Messages go to PostgreSQL only. That is where v2 reads chat from; the Mongo + * message store is the backend's fallback for a Postgres outage, and a seed + * that silently fell back would leave the screenshots reading an empty pod. + */ +import bcrypt from 'bcryptjs'; +import { randomBytes } from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import type { Types } from 'mongoose'; +import User from '../../models/User'; +import Pod from '../../models/Pod'; +import Task from '../../models/Task'; +import AgentMemory from '../../models/AgentMemory'; +import Integration from '../../models/Integration'; +import { AgentInstallation } from '../../models/AgentRegistry'; +import { assertCredOutOutsideRepo } from './guard'; +import { UI_CARD_CATALOGUE, validateAgentCardPayload } from '../../services/uiCardValidation'; +import { + AGENTS, CONNECTED_HOSTED_ENTRY, HUMANS, MEMORIES, MESSAGES, POD, SEAT, SEED_EMAIL_DOMAIN, + SEED_EPOCH, TASKS, agentUsername, seedObjectId, +} from './fixtures'; + +export interface PgPoolLike { + query: (sql: string, params?: unknown[]) => Promise<{ rows: Array> }>; +} + +export interface StagingSeedOptions { + pool: PgPoolLike; + /** Where the seat password is written (0600) when it is created or rotated. */ + credOut: string; + rotate?: boolean; + /** Clock for Postgres message timestamps; defaults to now. */ + now?: () => Date; +} + +export interface WriteTally { upserted: number; modified: number } +export interface StagingSeedReport { + mongo: Record<'users' | 'pods' | 'installations' | 'tasks' | 'memory' | 'connectors', WriteTally>; + pg: { users: number; pods: number; members: number; messages: number }; + seatPassword: 'created' | 'rotated' | 'unchanged'; + podId: string; +} + +const tally = (): WriteTally => ({ upserted: 0, modified: 0 }); +const count = (into: WriteTally, write: { upsertedCount?: number; modifiedCount?: number }) => { + into.upserted += write.upsertedCount || 0; + into.modified += write.modifiedCount || 0; +}; + +const idOf = (key: string): Types.ObjectId => seedObjectId(key); +const agentByKey = new Map(AGENTS.map((agent) => [agent.key, agent])); +const humanByKey = new Map([...HUMANS, SEAT].map((human) => [human.key, human])); + +/** The label chat renders for an author; mirrors AgentIdentityService.chatUsernameFor. */ +const chatName = (key: string): string => agentByKey.get(key)?.displayName || humanByKey.get(key)?.displayName || key; +const usernameOf = (key: string): string => { + const agent = agentByKey.get(key); + if (agent) return agentUsername(agent); + const human = humanByKey.get(key); + if (!human) throw new Error(`unknown seed author ${key}`); + return human.username; +}; + +/** An unusable password: a hash of random bytes nobody keeps. Humans in the sample never log in. */ +const unusablePasswordHash = () => bcrypt.hash(randomBytes(32).toString('hex'), 10); + +const writeCredFile = (file: string, body: Record): void => { + fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); + const tmp = `${file}.${process.pid}.tmp`; + // Exclusive create: a leftover or planted file at the temp path is replaced, + // never written through (a symlink there would otherwise be followed). + fs.rmSync(tmp, { force: true }); + fs.writeFileSync(tmp, `${JSON.stringify(body, null, 2)}\n`, { mode: 0o600, flag: 'wx' }); + fs.chmodSync(tmp, 0o600); + fs.renameSync(tmp, file); +}; + +async function seedUsers(report: StagingSeedReport, options: StagingSeedOptions): Promise { + const users = report.mongo.users; + for (const human of HUMANS) { + count(users, await User.updateOne( + { _id: idOf(human.key) }, + { + $set: { username: human.username, displayName: human.displayName, verified: true, role: 'user', isBot: false }, + $setOnInsert: { + email: `${human.username}@${SEED_EMAIL_DOMAIN}`, + password: await unusablePasswordHash(), + createdAt: SEED_EPOCH, + updatedAt: SEED_EPOCH, + }, + }, + { upsert: true, timestamps: false }, + )); + } + + for (const agent of AGENTS) { + count(users, await User.updateOne( + { _id: idOf(agent.key) }, + { + $set: { + username: agentUsername(agent), + isBot: true, + botType: 'agent', + verified: true, + role: 'user', + 'botMetadata.displayName': agent.displayName, + 'botMetadata.description': agent.description, + 'botMetadata.agentName': agent.agentName, + 'botMetadata.instanceId': agent.instanceId, + 'botMetadata.runtime': agent.agentName, + 'botMetadata.officialAgent': false, + }, + $setOnInsert: { + // The reserved agent address namespace (agentIdentityService.AGENT_ADDRESS_DOMAIN). + email: `${agentUsername(agent)}@agents.commonly.local`, + password: await unusablePasswordHash(), + createdAt: SEED_EPOCH, + updatedAt: SEED_EPOCH, + }, + }, + { upsert: true, timestamps: false }, + )); + } + + // The seat: a real, non-admin login. Its password is generated only when the + // account is new or --rotate is passed, written to the cred file BEFORE the + // database (a failed DB write then leaves a stale file, never an account + // nobody can log into), and never printed. + const seatId = idOf(SEAT.key); + const exists = await User.exists({ _id: seatId }); + let passwordHash: string | null = null; + if (!exists || options.rotate) { + const password = randomBytes(18).toString('base64url'); + const email = `${SEAT.username}@${SEED_EMAIL_DOMAIN}`; + writeCredFile(options.credOut, { + target: 'commonly-staging', + username: SEAT.username, + email, + password, + writtenAt: new Date().toISOString(), + }); + passwordHash = await bcrypt.hash(password, 10); + report.seatPassword = exists ? 'rotated' : 'created'; + } + count(users, await User.updateOne( + { _id: seatId }, + { + $set: { + username: SEAT.username, + displayName: SEAT.displayName, + verified: true, + role: 'user', + isBot: false, + ...(passwordHash ? { password: passwordHash } : {}), + }, + // A rotation ends every session the old password opened (models/User pre-save does the same). + ...(passwordHash && exists ? { $inc: { tokenVersion: 1 } } : {}), + $setOnInsert: { email: `${SEAT.username}@${SEED_EMAIL_DOMAIN}`, createdAt: SEED_EPOCH, updatedAt: SEED_EPOCH }, + }, + { upsert: true, timestamps: false }, + )); +} + +async function seedPod(report: StagingSeedReport): Promise { + const podId = idOf(POD.key); + const creator = idOf(HUMANS[0].key); + // The seat installs the agents, so its owner-only views (agent settings, + // Your Team) have something to show. + const installer = idOf(SEAT.key); + const members = [...HUMANS.map((h) => idOf(h.key)), idOf(SEAT.key), ...AGENTS.map((a) => idOf(a.key))]; + count(report.mongo.pods, await Pod.updateOne( + { _id: podId }, + { + $set: { + name: POD.name, + description: POD.description, + type: 'team', + joinPolicy: 'invite-only', + publicRead: false, + communityListed: false, + createdBy: creator, + }, + // $addToSet, not $set: people who joined through the UI survive a re-run. + $addToSet: { members: { $each: members } }, + $setOnInsert: { createdAt: SEED_EPOCH, updatedAt: SEED_EPOCH }, + }, + { upsert: true, timestamps: false }, + )); + + for (const agent of AGENTS) { + count(report.mongo.installations, await AgentInstallation.updateOne( + { agentName: agent.agentName, podId, instanceId: agent.instanceId }, + { + $set: { + displayName: agent.displayName, + version: '1.0.0', + status: 'active', + installedBy: installer, + scopes: [], + // BYO identity: the seat's own machine would run it; nothing is provisioned. + 'config.runtime': { runtimeType: agent.agentName, host: 'byo' }, + }, + $setOnInsert: { _id: idOf(`install:${agent.key}`), createdAt: SEED_EPOCH, updatedAt: SEED_EPOCH }, + }, + { upsert: true, timestamps: false }, + )); + } + return members; +} + +async function seedTasks(report: StagingSeedReport): Promise { + const podId = idOf(POD.key); + for (const [index, task] of TASKS.entries()) { + const assigneeId = idOf(task.assignee); + const claimed = task.status === 'claimed'; + const done = task.status === 'done'; + count(report.mongo.tasks, await Task.updateOne( + { podId, taskId: task.taskId }, + { + $set: { + taskNum: index + 1, + title: task.title, + priority: task.priority, + status: task.status, + // The board resolves an assignee against pod member usernames. + assignee: usernameOf(task.assignee), + dep: task.dep || null, + // The board resolves a holder by member id. BOTH lease timestamps are + // null on purpose: with claimExpiresAt null the lease is derived as + // claimedAt + 30 min (tasksApi deriveLeaseState, kernelWorkSweepService + // lapsedConditions), so a fixed past claimedAt lapses at once and the + // sweep hands the row back to pending. A claim with neither timestamp + // reads `held`, and the board shows "claimed by" with no countdown + // (a far-future expiry would render a countdown of millions of minutes). + claimedBy: claimed ? String(assigneeId) : null, + ...(claimed ? { claimedByUserId: String(assigneeId) } : {}), + claimedAt: null, + claimExpiresAt: null, + completedAt: done ? SEED_EPOCH : null, + prUrl: task.prUrl || null, + notes: task.notes || null, + source: 'human', + // Fixed _id: an auto-generated one would make every re-run a write. + updates: [{ + _id: idOf(`task-update:${task.taskId}`), + text: `${chatName(task.assignee)} ${done ? 'finished this' : claimed ? 'claimed this' : 'was assigned this'}`, + author: chatName(task.assignee), + authorId: String(assigneeId), + createdAt: SEED_EPOCH, + }], + }, + $setOnInsert: { _id: idOf(`task:${task.taskId}`), createdAt: SEED_EPOCH, updatedAt: SEED_EPOCH }, + }, + { upsert: true, timestamps: false }, + )); + } +} + +async function seedMemory(report: StagingSeedReport): Promise { + for (const memory of MEMORIES) { + const agent = agentByKey.get(memory.agentKey)!; + const section = (content: string) => ({ + content, visibility: 'private', updatedAt: SEED_EPOCH, byteSize: Buffer.byteLength(content, 'utf8'), + }); + // $setOnInsert: memory belongs to the agent once written; a re-run must not + // overwrite what an agent later saved there. + count(report.mongo.memory, await AgentMemory.updateOne( + { agentName: agent.agentName, instanceId: agent.instanceId }, + { + $setOnInsert: { + _id: idOf(`memory:${agent.key}`), + content: `${memory.soul}\n\n${memory.longTerm}`, + sections: { soul: section(memory.soul), long_term: section(memory.longTerm) }, + schemaVersion: 2, + createdAt: SEED_EPOCH, + updatedAt: SEED_EPOCH, + }, + }, + { upsert: true, timestamps: false }, + )); + } +} + +async function seedConnectors(report: StagingSeedReport): Promise { + // Connected, with no usable credential: no credentialRef/refreshTokenRef is + // written, so a tool call resolves no token and asks to reconnect. The + // available entry needs no row — the catalogue offers it to anyone. + count(report.mongo.connectors, await Integration.updateOne( + { type: 'hosted-mcp', createdBy: idOf(SEAT.key), 'config.entryId': CONNECTED_HOSTED_ENTRY }, + { + $set: { + scope: 'user', + status: 'connected', + isActive: true, + 'config.intake': 'oauth', + 'config.credentialHint': 'staging seed sample (no credential)', + }, + $setOnInsert: { _id: idOf(`connector:${CONNECTED_HOSTED_ENTRY}`), createdAt: SEED_EPOCH, updatedAt: SEED_EPOCH }, + }, + { upsert: true, timestamps: false }, + )); +} + +async function seedPostgres(report: StagingSeedReport, options: StagingSeedOptions, members: Types.ObjectId[]): Promise { + const { pool } = options; + const podId = String(idOf(POD.key)); + const creator = String(idOf(HUMANS[0].key)); + + // Look-then-insert rather than reading RETURNING off `ON CONFLICT DO NOTHING`: + // pg-mem returns the conflicting row there, so the tally would lie in tests. + // ON CONFLICT stays as the backstop for a concurrent writer. + const insertIfAbsent = async (exists: [string, unknown[]], insert: [string, unknown[]]): Promise => { + if ((await pool.query(exists[0], exists[1])).rows.length) return 0; + await pool.query(insert[0], insert[1]); + return 1; + }; + + const authors = [...HUMANS, SEAT].map((h) => ({ key: h.key, isBot: false })) + .concat(AGENTS.map((a) => ({ key: a.key, isBot: true }))); + for (const author of authors) { + const id = String(idOf(author.key)); + report.pg.users += await insertIfAbsent( + ['SELECT 1 FROM users WHERE _id = $1', [id]], + [`INSERT INTO users (_id, username, profile_picture, is_bot) VALUES ($1, $2, 'default', $3) + ON CONFLICT (_id) DO NOTHING`, [id, chatName(author.key), author.isBot]], + ); + } + + report.pg.pods += await insertIfAbsent( + ['SELECT 1 FROM pods WHERE id = $1', [podId]], + [`INSERT INTO pods (id, name, description, type, created_by) VALUES ($1, $2, $3, 'team', $4) + ON CONFLICT (id) DO NOTHING`, [podId, POD.name, POD.description, creator]], + ); + for (const member of members) { + report.pg.members += await insertIfAbsent( + ['SELECT 1 FROM pod_members WHERE pod_id = $1 AND user_id = $2', [podId, String(member)]], + [`INSERT INTO pod_members (pod_id, user_id) VALUES ($1, $2) + ON CONFLICT (pod_id, user_id) DO NOTHING`, [podId, String(member)]], + ); + } + + // Messages have no seed column, so the natural key is (pod, author, content); + // every seeded content string is unique. A message that is missing (first run, + // or removed by retention since) is inserted at its slot relative to now. + const now = (options.now || (() => new Date()))().getTime(); + const ids = new Map(); + for (const [index, message] of MESSAGES.entries()) { + const userId = String(idOf(message.author)); + const existing = await pool.query( + 'SELECT id FROM messages WHERE pod_id = $1 AND user_id = $2 AND content = $3 LIMIT 1', + [podId, userId, message.content], + ); + if (existing.rows[0]) { + ids.set(message.key, existing.rows[0].id); + continue; + } + let payload = message.payload || null; + if (message.messageType === 'card') { + payload = { ...message.payload, ...UI_CARD_CATALOGUE }; + validateAgentCardPayload('card', payload, message.content); + } + const threadRootId = message.threadOf ? ids.get(message.threadOf) : null; + if (message.threadOf && threadRootId == null) throw new Error(`thread root ${message.threadOf} was not written`); + const createdAt = new Date(now - (MESSAGES.length - index) * 60_000); + const { rows } = await pool.query( + `INSERT INTO messages (pod_id, user_id, content, message_type, payload, thread_root_id, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5::jsonb, $6::int, $7, $7) RETURNING id`, + [podId, userId, message.content, message.messageType, + payload == null ? null : JSON.stringify(payload), threadRootId ?? null, createdAt], + ); + ids.set(message.key, rows[0].id); + report.pg.messages += 1; + } +} + +export async function runStagingSeed(options: StagingSeedOptions): Promise { + assertCredOutOutsideRepo(options.credOut); + const report: StagingSeedReport = { + mongo: { + users: tally(), pods: tally(), installations: tally(), tasks: tally(), memory: tally(), connectors: tally(), + }, + pg: { users: 0, pods: 0, members: 0, messages: 0 }, + seatPassword: 'unchanged', + podId: String(idOf(POD.key)), + }; + await seedUsers(report, options); + const members = await seedPod(report); + await seedTasks(report); + await seedMemory(report); + await seedConnectors(report); + await seedPostgres(report, options, members); + return report; +} diff --git a/docs/development/README.md b/docs/development/README.md index 710ff98195..4715e77551 100644 --- a/docs/development/README.md +++ b/docs/development/README.md @@ -14,6 +14,7 @@ This directory contains development guides for frontend, backend, and code quali | [LITELLM.md](./LITELLM.md) | Optional LiteLLM model gateway for local development | | [local-credentials.md](./local-credentials.md) | Credential and env-flag surface for local agent development | | [review-checklist.md](./review-checklist.md) | Reviewer checklist — incident-derived rules, applied mid-PR. Companion to `/REVIEW.md` (the rubric) | +| [ui-validation.md](./ui-validation.md) | Seed staging with real-shaped data (`staging-seed`) before v2 screenshots, then what to check | | [agent-experience-audit.md](./agent-experience-audit.md) | Append-only AX log — surfaces that taught our own agent consumers a false model | ## Quick Commands diff --git a/docs/development/ui-validation.md b/docs/development/ui-validation.md new file mode 100644 index 0000000000..953089f5af --- /dev/null +++ b/docs/development/ui-validation.md @@ -0,0 +1,103 @@ +# UI validation + +How to get a v2 screen into a state worth screenshotting, and what to check once it is. + +## Step 0: seed staging (TASK-697) + +Run the staging seed before any `dev:live` or staging screenshot pass. An empty +pod hides every layout bug that only shows with real-shaped data: long agent +messages, a run of system lines, a card, a thread, a full board. + +One command, safe to re-run. A second run writes nothing: + +```bash +node dist/scripts/staging-seed.js --target=commonly-staging [--cred-out=] [--rotate] +``` + +(`npm run seed:staging -- --target=commonly-staging` inside `backend/` is the same thing.) + +What it writes, every row under a fixed id: + +- Pod **D215 sample**: invite-only, not publicly readable, not listed. Four agents + (Wren, Forge, Marlow, Tamsin, each with an `AgentInstallation` and a role line + in `botMetadata.description`) and the humans Maya Chen and Jon Park. There is no + stored tint field. v2 derives an agent's avatar ground from its `agentName:instanceId` + seed, so the four distinct identities produce the spread of tints. +- Board: 13 rows, at least 3 in each of pending, claimed, blocked and done, all + assigned. TASK-008 depends on TASK-004, and TASK-011 is done with a PR link. +- Chat, in PostgreSQL: three agent messages over 1,500 characters each, a run of + four `task-claimed` system lines, a rev-1 `commonly.ui` card (checked by + `services/uiCardValidation` before it is written), and a four-reply thread. +- Memory for Wren and Marlow, written once and never overwritten by later runs. +- Claimed rows carry no lease timestamps, so they read as held. A past `claimedAt` + would lapse within one sweep and the rows would drop back to pending. +- Members are added, never replaced, so people who joined through the UI stay. +- The seat test account **`d215-seat`**: a non-admin member of the pod, and the + installer of the four agents, so its owner views list them. It has one + connected hosted connector (Linear, with no credential, so any call asks to + reconnect) and one connector that is available but not connected (Notion). + +### The seat credential + +The seat password is generated only when the account is first created, or when you +pass `--rotate`. It is written only to the `--cred-out` file, mode 0600, and never to +stdout or logs. Without `--rotate`, a re-run leaves the password and the file untouched. +If you lose the file, re-run with `--rotate`. That also ends the seat's open sessions. + +Sign in with the `email` from the credentials file (login is by email, not username) +and its `password`. The file is written before the database. So if a `--rotate` +fails on the database write, the file holds a password the account never got, and +the fix is to rotate again. + +- **Where the operator keeps it:** `~/.commonly/credentials/staging-seat-test.json` + on the operator's machine (directory 0700). Never inside a repo: the script refuses + a `--cred-out` with a `.git` anywhere above it. +- That same path, under the process's home, is the script's default `--cred-out`. + In the cluster, write to `/tmp` instead and copy the file out (see below). + +### Running it on staging + +Staging has no ops-runner, so exec into the staging backend in namespace +`commonly-staging`. The image already holds the compiled script at +`dist/scripts/staging-seed.js`, plus the staging `MONGO_URI` and `PG_*`. Pin one pod, +so that all three steps reach the same filesystem: + +```bash +POD=$(kubectl get pod -n commonly-staging -o name | grep '^pod/backend-' | grep -v worker | head -1) + +kubectl exec -n commonly-staging "$POD" -- \ + node dist/scripts/staging-seed.js --target=commonly-staging --cred-out=/tmp/staging-seat-test.json + +# Only when the run says "credentials written" (first run, or --rotate): +mkdir -p -m 700 ~/.commonly/credentials +(umask 077; kubectl exec -n commonly-staging "$POD" -- cat /tmp/staging-seat-test.json \ + > ~/.commonly/credentials/staging-seat-test.json) +kubectl exec -n commonly-staging "$POD" -- rm -f /tmp/staging-seat-test.json +``` + +The script refuses before it writes anything unless all of these say staging +(`backend/scripts/staging-seed/guard.ts`): + +- `--target=commonly-staging` was passed. +- The pod's own namespace (the service-account namespace file, else `POD_NAMESPACE`) + is `commonly-staging`. +- Every `MONGO_URI` host is the in-cluster `mongodb` Service. `mongodb+srv` (Atlas) + is refused. +- `PG_DATABASE` is `commonly_staging`. A `PG_HOST` that names another namespace's + Service is refused. + +Run against prod's namespace, Atlas or `defaultdb`, it exits 1 with the reason. + +**Caveat:** if staging restricts sign-in (`AUTH_ALLOWED_EMAILS` / `AUTH_ALLOWED_DOMAINS`), +the seat's address `d215-seat@seed.staging.commonly.me` must be allowed, or the login is refused. + +## Then: check the screens + +- Layout, overflow and scroll need a real browser (CLAUDE.md design rule 9). + Sign in as `d215-seat`, open the D215 sample pod, and check at 1440 and 390 wide, + in light and in dark. +- Chat should show the long messages wrapping, the four claimed lines in a row, + the card rendered (not its fallback text), and the thread under Marlow's review. +- Board should show every column populated, the blocked row's dependency, and the + done row's PR link. +- Connectors should show Linear as connected and Notion as available.