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
174 changes: 174 additions & 0 deletions scripts/validate-team-spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/**
* Validates a team spec object against the cloud TeamSpec contract.
*
* Mirrors the rules enforced by cloud's `loadTeamSpec`
* (packages/core/src/proactive-runtime/team-spec.ts) plus the Phase-1
* `bindTeam` restrictions (packages/web/lib/proactive-runtime/team-deploy.ts):
*
* - `id`, `lead` are non-empty strings; `id` must match the team directory.
* - `members` is a non-empty array of { name, persona, role?, owns? } with
* unique names.
* - A persona ref is a non-empty string slug, or an object carrying at least
* one of `slug` / `path` / `inline`. Phase-1 binding rejects `inline`, so
* we do too.
* - No `owns` selector may be claimed by two different members.
* - `tokenBudget` / `timeBudgetSeconds` are positive 32-bit integers.
*
* Returns an array of human-readable error strings; empty means valid.
*/

const POSTGRES_INTEGER_MAX = 2_147_483_647;

function isRecord(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

// Order-independent key for an owns selector, matching cloud's stableJson so
// the double-claim check agrees with what bindTeam would reject.
function stableJson(value) {
if (Array.isArray(value)) {
return `[${value.map((entry) => stableJson(entry)).join(',')}]`;
}
if (isRecord(value)) {
return `{${Object.keys(value)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`)
.join(',')}}`;
}
return JSON.stringify(value);
}

function nonEmptyString(value) {
return typeof value === 'string' && value.trim().length > 0;
}

function positiveInt32(value) {
return (
typeof value === 'number' &&
Number.isInteger(value) &&
value > 0 &&
value <= POSTGRES_INTEGER_MAX
);
}

function validatePersonaRef(value, path, errors) {
if (typeof value === 'string') {
if (!nonEmptyString(value)) {
errors.push(`${path} must be a non-empty string`);
}
return;
}
if (!isRecord(value)) {
errors.push(`${path} must be a string or object`);
return;
}
if (value.slug !== undefined && !nonEmptyString(value.slug)) {
errors.push(`${path}.slug must be a non-empty string`);
}
if (value.path !== undefined && !nonEmptyString(value.path)) {
errors.push(`${path}.path must be a non-empty string`);
}
if (value.version !== undefined) {
const versionOk =
(typeof value.version === 'string' && nonEmptyString(value.version)) ||
(typeof value.version === 'number' &&
Number.isInteger(value.version) &&
value.version > 0);
if (!versionOk) {
errors.push(`${path}.version must be a non-empty string or positive integer`);
}
}
if (value.inline !== undefined) {
// loadTeamSpec accepts inline refs, but Phase-1 bindTeam rejects them
// ("deploy the persona first and reference it by slug or path"), so a
// checked-in spec with an inline ref could never bind.
errors.push(`${path}.inline is not supported by Phase-1 team binding`);
return;
}
if (value.slug === undefined && value.path === undefined) {
errors.push(`${path} must include slug or path`);
}
}

export function validateTeamSpec(spec, { expectedId } = {}) {
const errors = [];
if (!isRecord(spec)) {
return ['team spec must be an object'];
}

if (!nonEmptyString(spec.id)) {
errors.push('id must be a non-empty string');
} else if (expectedId !== undefined && spec.id !== expectedId) {
errors.push(`id "${spec.id}" must match team directory "${expectedId}"`);
}

if (!nonEmptyString(spec.lead)) {
errors.push('lead must be a non-empty string');
}

if (!Array.isArray(spec.members) || spec.members.length === 0) {
errors.push('members must be a non-empty array');
return errors;
}

const names = new Set();
const ownedSelectors = new Map();
spec.members.forEach((member, index) => {
const path = `members[${index}]`;
if (!isRecord(member)) {
errors.push(`${path} must be an object`);
return;
}
if (!nonEmptyString(member.name)) {
errors.push(`${path}.name must be a non-empty string`);
} else if (names.has(member.name)) {
errors.push(`duplicate member name "${member.name}"`);
} else {
names.add(member.name);
}
validatePersonaRef(member.persona, `${path}.persona`, errors);
if (member.role !== undefined && !nonEmptyString(member.role)) {
errors.push(`${path}.role must be a non-empty string`);
}
if (member.owns !== undefined) {
if (!Array.isArray(member.owns)) {
errors.push(`${path}.owns must be an array`);
} else {
member.owns.forEach((selector, selectorIndex) => {
if (!isRecord(selector)) {
errors.push(`${path}.owns[${selectorIndex}] must be an object`);
return;
}
const key = stableJson(selector);
const existingOwner = ownedSelectors.get(key);
if (ownedSelectors.has(key) && existingOwner !== member.name) {
errors.push(
`owns selector ${key} is claimed by both "${existingOwner}" and "${member.name}"`,
);
}
ownedSelectors.set(key, member.name);
});
}
}
});

if (spec.delegation !== undefined) {
if (!Array.isArray(spec.delegation)) {
errors.push('delegation must be an array');
} else {
spec.delegation.forEach((rule, index) => {
if (!isRecord(rule)) {
errors.push(`delegation[${index}] must be an object`);
}
});
}
}
if (spec.tokenBudget !== undefined && !positiveInt32(spec.tokenBudget)) {
errors.push('tokenBudget must be a positive 32-bit integer');
}
if (spec.timeBudgetSeconds !== undefined && !positiveInt32(spec.timeBudgetSeconds)) {
errors.push('timeBudgetSeconds must be a positive 32-bit integer');
}

return errors;
}
56 changes: 56 additions & 0 deletions teams/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Teams

Team specs for multi-agent issue solving. Each `teams/<id>/team.json` defines a
team roster the cloud team-binding API materializes into `teams` /
`team_members` rows.

## Contract

The schema is owned by cloud (`packages/core/src/proactive-runtime/team-spec.ts`,
`loadTeamSpec`) and enforced at bind time by `bindTeam`
(`packages/web/lib/proactive-runtime/team-deploy.ts`):

- `id` — team slug; must match the directory name. Binding upserts on
`(workspace, slug)`, so re-binding the same id updates the roster in place.
- `lead` — a member `name`, or (when not a member) the `deployedName` of an
already-deployed agent in the workspace. Binding fails closed
(`409 team_lead_not_deployed`) if neither resolves.
- `members[]` — `{ name, persona, role?, owns? }`. Member names are unique.
Persona refs are deployed-persona slugs (string or `{ "slug": … }`); Phase-1
binding rejects `inline` personas, and `path` refs resolve to their basename
slug. Every referenced persona must already be deployed in the workspace.
- `owns[]` selectors must not be claimed by two different members.
- `tokenBudget` / `timeBudgetSeconds` — positive 32-bit integers.

`npm test` validates every spec here against these rules
(`tests/team-spec.test.mjs`), so contract drift fails in CI instead of as a
4xx at bind time.

## Binding

POST the spec to the workspace teams route:

```bash
curl -sS -X POST "$CLOUD_API_BASE/api/v1/workspaces/$WORKSPACE_ID/teams" \
-H "Authorization: Bearer $CLOUD_API_TOKEN" \
-H "Content-Type: application/json" \
--data @teams/cloud-team-issue/team.json
```

## cloud-team-issue

Multi-member roster for the deployed `cloud-team-issue` teamSolve agent
(lead-outside-member-list shape: the lead is the deployed agent, the members
are the launchable workers). Both members reference the `cloud-team-issue`
persona slug — the only deployed teamSolve persona — and are distinguished by
`name`/`role`.

Binding this roster is one of **three** levers for team N>1 go-live; the other
two live in cloud and the roster stays dormant until they flip:

1. This roster bound (creates the `team_members` rows the delivery drain reads).
2. The `cloud-team-issue` persona's `capabilities.teamSolve.maxMembers` raised
from 1 (the drain re-derives the cap from the persona spec and truncates the
roster to it, logging dropped members).
3. `CLOUD_TEAM_LAUNCH_MULTI_ENABLED` flipped (cloud PR #1893's dispatcher
flag, default off).
23 changes: 23 additions & 0 deletions teams/cloud-team-issue/team.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"id": "cloud-team-issue",
"lead": "cloud-team-issue",
"members": [
{
"name": "implementer",
"persona": {
"slug": "cloud-team-issue"
},
"role": "implementer"
},
{
"name": "reviewer",
"persona": {
"slug": "cloud-team-issue"
},
"role": "reviewer"
}
],
"delegation": [],
"tokenBudget": 400000,
"timeBudgetSeconds": 1800
}
136 changes: 136 additions & 0 deletions tests/team-spec.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import assert from 'node:assert/strict';
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import test from 'node:test';

import { validateTeamSpec } from '../scripts/validate-team-spec.mjs';

/**
* Golden guard for every checked-in team spec: each `teams/<id>/team.json`
* must satisfy the cloud TeamSpec contract (see scripts/validate-team-spec.mjs
* for the mirrored rules) so the file can be POSTed to the team-binding route
* verbatim. A spec that drifts from the contract fails at bind time with a
* 4xx in production — this test moves that failure to CI.
*/

const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
const teamsRoot = join(repoRoot, 'teams');

const teamDirs = existsSync(teamsRoot)
? readdirSync(teamsRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
: [];

test('teams/ contains at least one team spec', () => {
assert.ok(teamDirs.length > 0, 'expected at least one directory under teams/');
});

for (const teamDir of teamDirs) {
test(`teams/${teamDir}/team.json satisfies the cloud TeamSpec contract`, () => {
const specPath = join(teamsRoot, teamDir, 'team.json');
assert.ok(existsSync(specPath), `missing ${specPath}`);
const spec = JSON.parse(readFileSync(specPath, 'utf8'));
const errors = validateTeamSpec(spec, { expectedId: teamDir });
assert.deepEqual(errors, [], `invalid team spec teams/${teamDir}/team.json`);
});
}

test('cloud-team-issue roster references the deployed teamSolve persona', () => {
const specPath = join(teamsRoot, 'cloud-team-issue', 'team.json');
const spec = JSON.parse(readFileSync(specPath, 'utf8'));
assert.deepEqual(
spec.members.map((member) => member.persona?.slug ?? member.persona),
['cloud-team-issue', 'cloud-team-issue'],
);
});

// Validator self-checks: prove each contract rule actually rejects, so a
// future edit that loosens the validator cannot silently green the suite.
const validSpec = {
id: 'example',
lead: 'example-lead',
members: [
{ name: 'a', persona: { slug: 'persona-a' }, role: 'implementer' },
{ name: 'b', persona: 'persona-b' },
],
tokenBudget: 400000,
timeBudgetSeconds: 1800,
};

test('validator accepts a known-good spec', () => {
assert.deepEqual(validateTeamSpec(validSpec, { expectedId: 'example' }), []);
});

const rejectionCases = [
{
label: 'id/directory mismatch',
spec: { ...validSpec, id: 'other' },
expectedId: 'example',
needle: 'must match team directory',
},
{
label: 'empty members',
spec: { ...validSpec, members: [] },
needle: 'members must be a non-empty array',
},
{
label: 'duplicate member names',
spec: {
...validSpec,
members: [
{ name: 'a', persona: 'persona-a' },
{ name: 'a', persona: 'persona-b' },
],
},
needle: 'duplicate member name',
},
{
label: 'persona ref without slug or path',
spec: { ...validSpec, members: [{ name: 'a', persona: {} }] },
needle: 'must include slug or path',
},
{
label: 'inline persona ref (unsupported in Phase-1 binding)',
spec: { ...validSpec, members: [{ name: 'a', persona: { inline: {} } }] },
needle: 'inline is not supported',
},
{
label: 'owns selector claimed by two members',
spec: {
...validSpec,
members: [
{ name: 'a', persona: 'persona-a', owns: [{ provider: 'github' }] },
{ name: 'b', persona: 'persona-b', owns: [{ provider: 'github' }] },
],
},
needle: 'claimed by both',
},
{
label: 'owns selector claimed by an invalid empty-name member',
spec: {
...validSpec,
members: [
{ name: '', persona: 'persona-a', owns: [{ provider: 'github' }] },
{ name: 'b', persona: 'persona-b', owns: [{ provider: 'github' }] },
],
},
needle: 'claimed by both',
},
{
label: 'non-integer token budget',
spec: { ...validSpec, tokenBudget: 1.5 },
needle: 'tokenBudget must be a positive 32-bit integer',
},
];

for (const { label, spec, expectedId, needle } of rejectionCases) {
test(`validator rejects: ${label}`, () => {
const errors = validateTeamSpec(spec, { expectedId });
assert.ok(
errors.some((error) => error.includes(needle)),
`expected an error containing "${needle}", got: ${JSON.stringify(errors)}`,
);
});
}