Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
319 changes: 319 additions & 0 deletions backend/__tests__/unit/scripts/stagingSeed.test.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
98 changes: 98 additions & 0 deletions backend/scripts/staging-seed.ts
Original file line number Diff line number Diff line change
@@ -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=<path>] [--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<unknown>;
disconnect: () => Promise<unknown>;
/** Loaded only after the guard: config/db-pg builds its pool on import. */
loadPool: () => PgPoolLike & { end?: () => Promise<unknown> };
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<StagingSeedMainDeps> = {}): Promise<StagingSeedReport> {
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);
});
}
199 changes: 199 additions & 0 deletions backend/scripts/staging-seed/fixtures.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
/** 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' },
];
Loading
Loading