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
3 changes: 2 additions & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ jobs:
# events (leaf — consumed by runtime and Cloud)
# persona-kit (leaf — consumed by everyone)
# runtime (→ persona-kit; consumed by deploy + mcp-workforce)
# compose (leaf — TeamSpec parsing and preview planning)
# delivery (→ runtime; consumed by external agents)
# workload-router (→ persona-kit; consumed by cli)
# deploy (→ persona-kit + runtime; consumed by cli)
Expand All @@ -97,7 +98,7 @@ jobs:
# cli (→ persona-kit + workload-router + deploy)
# agentworkforce (→ cli — umbrella wrapper, must publish last)
# personas-core publishes via the separate publish-personas.yml workflow.
echo "packages=events persona-kit runtime delivery workload-router deploy mcp-workforce daytona-runner cli agentworkforce" >> "$GITHUB_OUTPUT"
echo "packages=events persona-kit runtime compose delivery workload-router deploy mcp-workforce daytona-runner cli agentworkforce" >> "$GITHUB_OUTPUT"

# Lockstep baseline heal. The workspace publishes every package at the
# same version, so if any package's local version lags either its own
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/verify-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ on:
type: choice
options:
- '@agentworkforce/events'
- '@agentworkforce/compose'
- '@agentworkforce/cli'
- 'agentworkforce'
- '@agentworkforce/workload-router'
Expand Down
5 changes: 5 additions & 0 deletions packages/compose/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# @agentworkforce/compose

Portable TeamSpec validation and stable Compose references/plans for
AgentWorkforce. This package is the source of truth consumed by authoring,
Cloud, and agent repositories; Relayflows remains the workflow-schema owner.
33 changes: 33 additions & 0 deletions packages/compose/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"name": "@agentworkforce/compose",
Comment thread
miyaontherelay marked this conversation as resolved.
"version": "0.1.0",
"private": false,
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./schema": {
"types": "./dist/schema.d.ts",
"default": "./dist/schema.js"
},
"./package.json": "./package.json"
},
"files": ["dist", "README.md", "package.json"],
"repository": {
"type": "git",
"url": "https://github.com/AgentWorkforce/workforce",
"directory": "packages/compose"
},
"publishConfig": { "access": "public" },
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "tsc -p tsconfig.json && node --test dist/**/*.test.js dist/*.test.js",
"lint": "tsc -p tsconfig.json --noEmit"
}
}
25 changes: 25 additions & 0 deletions packages/compose/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export { TEAM_SPEC_JSON_SCHEMA } from './schema.js';
export {
findTeamSpecFromPersonaDir,
loadTeamSpec,
loadTeamSpecFile,
loadTeamSpecFromPersonaDir,
parseTeamSpecFile,
TEAM_SPEC_FILENAME,
TeamSpecError,
validateTeamSpec
} from './team-spec.js';
export { createComposePreviewResult, planTeamSpec } from './plan.js';
export type {
ComposePlanEdgeV1,
ComposePlanNodeV1,
ComposePlanV1,
ComposeRef,
ComposeResultV1,
DelegationRule,
PersonaRef,
TeamMember,
TeamRef,
TeamSpec,
TriggerSelector
} from './types.js';
53 changes: 53 additions & 0 deletions packages/compose/src/plan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { ComposePlanV1, ComposeRef, ComposeResultV1, TeamSpec } from './types.js';
import { loadTeamSpec } from './team-spec.js';

/** Pure TeamSpec planner used by preview mode; it never launches child Runs. */
export function planTeamSpec(input: unknown, ref?: ComposeRef): ComposePlanV1 {
const spec = loadTeamSpec(input);
const composeRef: ComposeRef = ref ?? { kind: 'team', ref: spec.id };
if (composeRef.kind !== 'team') throw new Error('planTeamSpec requires a team ComposeRef');
const nodes: ComposePlanV1['nodes'] = [];
if (!spec.members.some((member) => member.name === spec.lead)) {
nodes.push({ id: spec.lead, kind: 'lead' });
}
for (const member of spec.members) {
nodes.push({
id: member.name,
kind: member.name === spec.lead ? 'lead' : 'member',
persona: member.persona,
...(member.role ? { role: member.role } : {}),
...(member.owns ? { owns: member.owns } : {})
});
}
return {
schemaVersion: 1,
composeId: spec.id,
kind: 'team',
ref: composeRef,
status: 'valid',
nodes,
edges: spec.delegation === undefined
? spec.members
.filter((member) => member.name !== spec.lead)
.map((member) => ({ from: spec.lead, to: member.name, kind: 'delegates' as const }))
: [],
...(spec.delegation !== undefined ? { delegation: spec.delegation.map((rule) => ({ ...rule })) } : {}),
...((spec.tokenBudget !== undefined || spec.timeBudgetSeconds !== undefined) ? {
budget: {
...(spec.tokenBudget !== undefined ? { tokenBudget: spec.tokenBudget } : {}),
...(spec.timeBudgetSeconds !== undefined ? { timeBudgetSeconds: spec.timeBudgetSeconds } : {})
}
} : {}),
errors: []
};
}

export function createComposePreviewResult(plan: ComposePlanV1): ComposeResultV1 {
return {
schemaVersion: 1 as const,
composeId: plan.composeId,
status: 'previewed' as const,
plan,
childRunIds: []
};
}
44 changes: 44 additions & 0 deletions packages/compose/src/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
const nonBlankString = { type: 'string', minLength: 1, pattern: '\\S' } as const;

export const TEAM_SPEC_JSON_SCHEMA = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
$id: 'https://agentworkforce.dev/schemas/compose/team-spec.json',
title: 'TeamSpec',
type: 'object',
required: ['id', 'lead', 'members'],
additionalProperties: false,
properties: {
id: nonBlankString,
lead: nonBlankString,
members: {
type: 'array', minItems: 1,
items: {
type: 'object', required: ['name', 'persona'], additionalProperties: false,
properties: {
name: nonBlankString,
persona: {
oneOf: [
nonBlankString,
{
type: 'object',
additionalProperties: false,
properties: {
slug: nonBlankString,
version: { oneOf: [nonBlankString, { type: 'integer', minimum: 1 }] },
path: nonBlankString,
inline: { type: 'object' }
},
anyOf: [{ required: ['slug'] }, { required: ['path'] }, { required: ['inline'] }]
}
]
},
role: nonBlankString,
owns: { type: 'array', items: { type: 'object' } }
}
}
},
delegation: { type: 'array', items: { type: 'object' } },
tokenBudget: { type: 'integer', minimum: 1, maximum: 2147483647 },
timeBudgetSeconds: { type: 'integer', minimum: 1, maximum: 2147483647 }
}
} as const;
77 changes: 77 additions & 0 deletions packages/compose/src/team-spec.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import assert from 'node:assert/strict';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, relative } from 'node:path';
import test from 'node:test';
import {
createComposePreviewResult,
loadTeamSpec,
parseTeamSpecFile,
planTeamSpec,
TEAM_SPEC_JSON_SCHEMA,
TeamSpecError,
validateTeamSpec
} from './index.js';

const SPEC = {
id: 'cloud-team-issue',
lead: 'cloud-team-issue',
members: [
{ name: 'implementer', persona: { slug: 'cloud-team-implementer' }, role: 'implementer' },
{ name: 'reviewer', persona: { slug: 'cloud-team-reviewer' }, role: 'reviewer' }
],
tokenBudget: 400000,
timeBudgetSeconds: 1800
};

test('loads the canonical team.json shape from spec section 12.3', () => {
assert.deepEqual(loadTeamSpec(SPEC), SPEC);
assert.deepEqual(validateTeamSpec(SPEC, { expectedId: 'cloud-team-issue' }), []);
assert.equal(TEAM_SPEC_JSON_SCHEMA.properties.members.minItems, 1);
});

test('matches Cloud validation for persona refs, budgets, names, and ownership', () => {
assert.throws(() => loadTeamSpec({ ...SPEC, tokenBudget: 0 }), /positive 32-bit integer/);
assert.throws(() => loadTeamSpec({ ...SPEC, members: [{ name: 'x', persona: { version: 1 } }] }), /must include slug, path, or inline/);
assert.throws(() => loadTeamSpec({ ...SPEC, members: [{ name: 'x', persona: 'a' }, { name: 'x', persona: 'b' }] }), /duplicate member name/);
assert.throws(() => loadTeamSpec({ ...SPEC, members: [
{ name: 'a', persona: 'a', owns: [{ provider: 'linear', on: 'issue.created' }] },
{ name: 'b', persona: 'b', owns: [{ on: 'issue.created', provider: 'linear' }] }
] }), /claimed by both/);
assert.throws(() => loadTeamSpec(null), TeamSpecError);
assert.throws(() => loadTeamSpec({ ...SPEC, tokenBudegt: 10 }), /TeamSpec\.tokenBudegt is not a known field/);
assert.throws(() => loadTeamSpec({ ...SPEC, members: [{ name: 'x', persona: 'x', typo: true }] }), /members\[0\]\.typo is not a known field/);
assert.throws(() => loadTeamSpec({ ...SPEC, members: [{ name: 'x', persona: { slug: 'x', typo: true } }] }), /persona\.typo is not a known field/);
assert.doesNotThrow(() => loadTeamSpec({ ...SPEC, members: [{ name: 'x', persona: 'x', owns: [{ optional: undefined }] }] }));
assert.equal(TEAM_SPEC_JSON_SCHEMA.properties.id.pattern, '\\S');
});

test('Compose preview resolves the roster and launches zero children', () => {
const plan = planTeamSpec(SPEC);
assert.equal(plan.status, 'valid');
assert.deepEqual(plan.nodes.map((node) => node.id), ['cloud-team-issue', 'implementer', 'reviewer']);
assert.deepEqual(plan.budget, { tokenBudget: 400000, timeBudgetSeconds: 1800 });
const result = createComposePreviewResult(plan);
assert.equal(result.status, 'previewed');
assert.deepEqual(result.childRunIds, []);

const delegation = [{ from: 'implementer', to: 'reviewer', when: 'ready' }];
const delegated = planTeamSpec({ ...SPEC, delegation });
assert.deepEqual(delegated.delegation, delegation);
assert.deepEqual(delegated.edges, []);
});

test('file parser requires team id to match its directory', async () => {
const root = await mkdtemp(join(tmpdir(), 'compose-team-'));
try {
const dir = join(root, 'expected');
await mkdir(dir);
const file = join(dir, 'team.json');
await writeFile(file, JSON.stringify({ ...SPEC, id: 'wrong' }));
await assert.rejects(() => parseTeamSpecFile(file), /must match team directory "expected"/);
await writeFile(file, JSON.stringify({ ...SPEC, id: 'expected' }));
assert.equal((await parseTeamSpecFile(relative(process.cwd(), file))).id, 'expected');
} finally {
await rm(root, { recursive: true, force: true });
}
});
Loading
Loading